Skip to main content

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