xcursor/
lib.rs

1//! A crate to load cursor themes, and parse XCursor files.
2
3use std::collections::HashSet;
4use std::env;
5use std::path::{Path, PathBuf};
6
7/// A module implementing XCursor file parsing.
8pub mod parser;
9
10/// A cursor theme.
11#[derive(Debug, PartialEq, Eq, Clone)]
12pub struct CursorTheme {
13    theme: CursorThemeIml,
14    /// Global search path for themes.
15    search_paths: Vec<PathBuf>,
16}
17
18impl CursorTheme {
19    /// Search for a theme with the given name in the given search paths,
20    /// and returns an XCursorTheme which represents it. If no inheritance
21    /// can be determined, then the themes inherits from the "default" theme.
22    pub fn load(name: &str) -> Self {
23        let search_paths = theme_search_paths(SearchPathsEnvironment::get());
24
25        let theme = CursorThemeIml::load(name, &search_paths);
26
27        CursorTheme {
28            theme,
29            search_paths,
30        }
31    }
32
33    /// Try to load an icon from the theme.
34    /// If the icon is not found within this theme's
35    /// directories, then the function looks at the
36    /// theme from which this theme is inherited.
37    pub fn load_icon(&self, icon_name: &str) -> Option<PathBuf> {
38        let mut walked_themes = HashSet::new();
39
40        self.theme
41            .load_icon_with_depth(icon_name, &self.search_paths, &mut walked_themes)
42            .map(|(pathbuf, _)| pathbuf)
43    }
44
45    /// Try to load an icon from the theme, returning it with its inheritance
46    /// depth.
47    ///
48    /// If the icon is not found within this theme's directories, then the
49    /// function looks at the theme from which this theme is inherited. The
50    /// second element of the returned tuple indicates how many levels of
51    /// inheritance were traversed before the icon was found.
52    pub fn load_icon_with_depth(&self, icon_name: &str) -> Option<(PathBuf, usize)> {
53        let mut walked_themes = HashSet::new();
54
55        self.theme
56            .load_icon_with_depth(icon_name, &self.search_paths, &mut walked_themes)
57    }
58}
59
60#[derive(Debug, PartialEq, Eq, Clone)]
61struct CursorThemeIml {
62    /// Theme name.
63    name: String,
64    /// Directories where the theme is presented and corresponding names of inherited themes.
65    /// `None` if theme inherits nothing.
66    data: Vec<(PathBuf, Option<String>)>,
67}
68
69impl CursorThemeIml {
70    /// The implementation of cursor theme loading.
71    fn load(name: &str, search_paths: &[PathBuf]) -> Self {
72        let mut data = Vec::new();
73
74        // Find directories where this theme is presented.
75        for mut path in search_paths.iter().cloned() {
76            path.push(name);
77            if path.is_dir() {
78                let data_dir = path.clone();
79
80                path.push("index.theme");
81                let inherits = if let Some(inherits) = theme_inherits(&path) {
82                    Some(inherits)
83                } else if name != "default" {
84                    Some(String::from("default"))
85                } else {
86                    None
87                };
88
89                data.push((data_dir, inherits));
90            }
91        }
92
93        CursorThemeIml {
94            name: name.to_owned(),
95            data,
96        }
97    }
98
99    /// The implementation of cursor icon loading.
100    fn load_icon_with_depth(
101        &self,
102        icon_name: &str,
103        search_paths: &[PathBuf],
104        walked_themes: &mut HashSet<String>,
105    ) -> Option<(PathBuf, usize)> {
106        for data in &self.data {
107            let mut icon_path = data.0.clone();
108            icon_path.push("cursors");
109            icon_path.push(icon_name);
110            if icon_path.is_file() {
111                return Some((icon_path, 0));
112            }
113        }
114
115        // We've processed all based theme files. Traverse inherited themes, marking this theme
116        // as already visited to avoid infinite recursion.
117        walked_themes.insert(self.name.clone());
118
119        for data in &self.data {
120            // Get inherited theme name, if any.
121            let inherits = match data.1.as_ref() {
122                Some(inherits) => inherits,
123                None => continue,
124            };
125
126            // We've walked this theme, avoid rebuilding.
127            if walked_themes.contains(inherits) {
128                continue;
129            }
130
131            let inherited_theme = CursorThemeIml::load(inherits, search_paths);
132
133            match inherited_theme.load_icon_with_depth(icon_name, search_paths, walked_themes) {
134                Some((icon_path, depth)) => return Some((icon_path, depth + 1)),
135                None => continue,
136            }
137        }
138
139        None
140    }
141}
142
143#[derive(Default)]
144struct SearchPathsEnvironment {
145    home: Option<String>,
146    xcursor_path: Option<String>,
147    xdg_data_home: Option<String>,
148    xdg_data_dirs: Option<String>,
149}
150
151impl SearchPathsEnvironment {
152    fn get() -> Self {
153        SearchPathsEnvironment {
154            home: env::var("HOME").ok().filter(|x| !x.is_empty()),
155            xcursor_path: env::var("XCURSOR_PATH").ok().filter(|x| !x.is_empty()),
156            xdg_data_home: env::var("XDG_DATA_HOME").ok().filter(|x| !x.is_empty()),
157            xdg_data_dirs: env::var("XDG_DATA_DIRS").ok().filter(|x| !x.is_empty()),
158        }
159    }
160}
161
162/// Get the list of paths where the themes have to be searched, according to the XDG Icon Theme
163/// specification. If `XCURSOR_PATH` is set, it will override the default search paths.
164fn theme_search_paths(environment: SearchPathsEnvironment) -> Vec<PathBuf> {
165    let home_dir = environment
166        .home
167        .as_ref()
168        .map(|home| Path::new(home.as_str()));
169
170    if let Some(xcursor_path) = environment.xcursor_path {
171        return xcursor_path
172            .split(':')
173            .flat_map(|entry| {
174                if entry.is_empty() {
175                    return None;
176                }
177                expand_home_dir(PathBuf::from(entry), home_dir)
178            })
179            .collect();
180    }
181
182    // The order is following other XCursor loading libs, like libwayland-cursor.
183    let mut paths = Vec::new();
184
185    if let Some(xdg_data_home) = environment.xdg_data_home {
186        paths.extend(expand_home_dir(PathBuf::from(xdg_data_home), home_dir));
187    } else if let Some(home_dir) = home_dir {
188        paths.push(home_dir.join(".local/share/icons"))
189    }
190
191    if let Some(home_dir) = home_dir {
192        paths.push(home_dir.join(".icons"));
193    }
194
195    if let Some(xdg_data_dirs) = environment.xdg_data_dirs {
196        paths.extend(xdg_data_dirs.split(':').flat_map(|entry| {
197            if entry.is_empty() {
198                return None;
199            }
200            let mut entry = expand_home_dir(PathBuf::from(entry), home_dir)?;
201            entry.push("icons");
202            Some(entry)
203        }))
204    } else {
205        paths.push(PathBuf::from("/usr/local/share/icons"));
206        paths.push(PathBuf::from("/usr/share/icons"));
207    }
208
209    paths.push(PathBuf::from("/usr/share/pixmaps"));
210
211    if let Some(home_dir) = home_dir {
212        paths.push(home_dir.join(".cursors"));
213    }
214
215    paths.push(PathBuf::from("/usr/share/cursors/xorg-x11"));
216
217    paths
218}
219
220/// If the first component of the path is `~`, replaces it with the home dir. If no home dir is
221/// present, returns `None`.
222fn expand_home_dir(path: PathBuf, home_dir: Option<&Path>) -> Option<PathBuf> {
223    let mut components = path.iter();
224    if let Some(first_component) = components.next() {
225        if first_component == "~" {
226            if let Some(home_dir) = home_dir {
227                let mut path = home_dir.to_path_buf();
228                for component in components {
229                    path.push(component);
230                }
231                return Some(path);
232            } else {
233                return None;
234            }
235        }
236    }
237    Some(path)
238}
239
240/// Load the specified index.theme file, and returns a `Some` with
241/// the value of the `Inherits` key in it.
242/// Returns `None` if the file cannot be read for any reason,
243/// if the file cannot be parsed, or if the `Inherits` key is omitted.
244fn theme_inherits(file_path: &Path) -> Option<String> {
245    let content = std::fs::read_to_string(file_path).ok()?;
246
247    parse_theme(&content)
248}
249
250/// Parse the content of the `index.theme` and return the `Inherits` value.
251fn parse_theme(content: &str) -> Option<String> {
252    const PATTERN: &str = "Inherits";
253
254    let is_xcursor_space_or_separator =
255        |&ch: &char| -> bool { ch.is_whitespace() || ch == ';' || ch == ',' };
256
257    for line in content.lines() {
258        // Line should start with `Inherits`, otherwise go to the next line.
259        if !line.starts_with(PATTERN) {
260            continue;
261        }
262
263        // Skip the `Inherits` part and trim the leading white spaces.
264        let mut chars = line.get(PATTERN.len()..).unwrap().trim_start().chars();
265
266        // If the next character after leading white spaces isn't `=` go the next line.
267        if Some('=') != chars.next() {
268            continue;
269        }
270
271        // Skip XCursor spaces/separators.
272        let result: String = chars
273            .skip_while(is_xcursor_space_or_separator)
274            .take_while(|ch| !is_xcursor_space_or_separator(ch))
275            .collect();
276
277        if !result.is_empty() {
278            return Some(result);
279        }
280    }
281
282    None
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::path::{Path, PathBuf};
289
290    #[test]
291    fn test_parse_theme() {
292        let theme_name = String::from("XCURSOR_RS");
293
294        let theme = format!("Inherits={}", theme_name.clone());
295
296        assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
297
298        let theme = format!(" Inherits={}", theme_name.clone());
299
300        assert_eq!(parse_theme(&theme), None);
301
302        let theme = format!(
303            "[THEME name]\nInherits   = ,;\t\t{};;;;Tail\n\n",
304            theme_name.clone()
305        );
306
307        assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
308
309        let theme = format!("Inherits;=;{}", theme_name.clone());
310
311        assert_eq!(parse_theme(&theme), None);
312
313        let theme = format!("Inherits = {}\n\nInherits=OtherTheme", theme_name.clone());
314
315        assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
316
317        let theme = format!(
318            "Inherits = ;;\nSome\tgarbage\nInherits={}",
319            theme_name.clone()
320        );
321
322        assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
323    }
324
325    #[test]
326    fn test_expand_home_dir() {
327        let home = Path::new("/home/user");
328
329        let result = expand_home_dir("~".into(), Some(home));
330        assert_eq!(result, Some("/home/user".into()));
331
332        let result = expand_home_dir("~/.icons".into(), Some(home));
333        assert_eq!(result, Some("/home/user/.icons".into()));
334
335        let result = expand_home_dir("~/.local/share/icons".into(), Some(home));
336        assert_eq!(result, Some("/home/user/.local/share/icons".into()));
337
338        let result = expand_home_dir("~/.icons".into(), None);
339        assert_eq!(result, None);
340
341        let path: PathBuf = "/usr/share/icons".into();
342        let result = expand_home_dir(path.clone(), Some(home));
343        assert_eq!(result, Some(path));
344
345        let path: PathBuf = "".into();
346        let result = expand_home_dir(path.clone(), Some(home));
347        assert_eq!(result, Some(path));
348
349        // ~ in the middle of path should not expand
350        let path: PathBuf = "/some/path/~/icons".into();
351        let result = expand_home_dir(path.clone(), Some(home));
352        assert_eq!(result, Some(path));
353    }
354
355    #[test]
356    fn test_theme_search_paths() {
357        assert_eq!(
358            theme_search_paths(SearchPathsEnvironment {
359                home: Some("/home/user".to_string()),
360                xdg_data_home: Some("/home/user/.data".to_string()),
361                xdg_data_dirs: Some("/opt/share::/usr/local/share:~/custom/share".to_string()),
362                ..Default::default()
363            }),
364            vec![
365                PathBuf::from("/home/user/.data"),
366                PathBuf::from("/home/user/.icons"),
367                PathBuf::from("/opt/share/icons"),
368                PathBuf::from("/usr/local/share/icons"),
369                PathBuf::from("/home/user/custom/share/icons"),
370                PathBuf::from("/usr/share/pixmaps"),
371                PathBuf::from("/home/user/.cursors"),
372                PathBuf::from("/usr/share/cursors/xorg-x11"),
373            ]
374        );
375
376        // XCURSOR_PATH overrides all other paths
377        assert_eq!(
378            theme_search_paths(SearchPathsEnvironment {
379                home: Some("/home/user".to_string()),
380                xcursor_path: Some("~/custom/xcursor/icons:/absolute-path/icons".to_string()),
381                ..Default::default()
382            }),
383            vec![
384                PathBuf::from("/home/user/custom/xcursor/icons"),
385                PathBuf::from("/absolute-path/icons")
386            ]
387        );
388
389        // no home causes tilde paths to be omitted
390        assert_eq!(
391            theme_search_paths(SearchPathsEnvironment {
392                xdg_data_home: Some("~/.data".to_string()),
393                ..Default::default()
394            }),
395            vec![
396                PathBuf::from("/usr/local/share/icons"),
397                PathBuf::from("/usr/share/icons"),
398                PathBuf::from("/usr/share/pixmaps"),
399                PathBuf::from("/usr/share/cursors/xorg-x11"),
400            ]
401        );
402    }
403}