Skip to main content

drmkit_cursor/
theme.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Resolving a cursor name to a file on disk.
5//!
6//! Walks the XDG cursor search path, indexes every theme that looks like it
7//! carries cursors, follows each theme's `Inherits=` chain, and expands cursor
8//! names through the alias table. What it does *not* do is read pixels: it
9//! returns a path and the theme it came from, which keeps it cheap and lets it
10//! be tested without a display or a megabyte of image data.
11
12use std::cell::RefCell;
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15
16use crate::CursorError;
17use crate::alias;
18
19/// Where a cursor name resolved to.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct ThemeResolution {
23    /// The theme the cursor was actually found in, which may not be the one
24    /// asked for -- the `Inherits=` chain or the fallback may have supplied it.
25    pub theme_name: String,
26    /// The cursor file on disk.
27    pub source: PathBuf,
28    /// Every theme considered, in order. For diagnostics: it answers "why did
29    /// I get this one" without re-running the walk.
30    pub chain: Vec<String>,
31}
32
33/// One indexed theme.
34#[derive(Debug)]
35struct Record {
36    name: String,
37    /// Every search-path directory providing this theme, in path priority
38    /// order. More than one is normal -- a user override under `~/.icons` and
39    /// the system copy under `/usr/share/icons` share a name.
40    dirs: Vec<PathBuf>,
41    inherits: Vec<String>,
42}
43
44/// What a resolve is keyed on. Both halves fully determine the outcome.
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46struct CacheKey {
47    cursor_name: String,
48    preferred_theme: String,
49}
50
51/// An index of the cursor themes on disk.
52///
53/// Cheap to construct -- a directory walk and a small metadata parse, no
54/// pixels. Resolutions are memoized for the index's lifetime, hits and misses
55/// alike, so a caller cycling through a fixed set of shapes pays the walk once
56/// per name.
57///
58/// The index is a snapshot: cursors appearing or disappearing on disk
59/// afterwards are not noticed. Build a new one, which is cheap.
60///
61/// Not `Sync`. The memo cache is a [`RefCell`], matching the reference, which
62/// documents `Theme` as unsynchronised. Sharing one across threads is a
63/// compile error rather than a race.
64#[derive(Debug)]
65pub struct Theme {
66    themes: Vec<Record>,
67    by_name: HashMap<String, usize>,
68    search_paths: Vec<PathBuf>,
69    cache: RefCell<HashMap<CacheKey, Result<ThemeResolution, CursorError>>>,
70}
71
72impl Theme {
73    /// Index the themes on the standard search path.
74    ///
75    /// # Errors
76    ///
77    /// [`CursorError::NoThemes`] when no theme directory is readable at all.
78    /// That is deliberately distinct from a resolve failing on a healthy
79    /// index: one means nothing is installed, the other means this cursor is
80    /// not in what *is* installed, and a caller wants to say different things
81    /// about them.
82    pub fn discover() -> Result<Self, CursorError> {
83        Self::discover_with_paths(&default_search_paths())
84    }
85
86    /// Index the themes under an explicit set of roots.
87    ///
88    /// The seam the tests use, so they can point at a fixture tree rather than
89    /// whatever the machine happens to have installed.
90    ///
91    /// # Errors
92    ///
93    /// As [`Theme::discover`].
94    pub fn discover_with_paths(search_paths: &[PathBuf]) -> Result<Self, CursorError> {
95        let mut themes: Vec<Record> = Vec::new();
96        let mut by_name: HashMap<String, usize> = HashMap::new();
97
98        for root in search_paths {
99            let Ok(entries) = std::fs::read_dir(root) else {
100                continue;
101            };
102            // Sorted, because `read_dir` order is whatever the filesystem
103            // feels like and discovery order decides the fallback order. An
104            // unsorted walk makes the last-resort theme choice differ between
105            // machines with identical trees.
106            let mut dirs: Vec<PathBuf> = entries
107                .flatten()
108                .map(|entry| entry.path())
109                .filter(|path| path.is_dir())
110                .collect();
111            dirs.sort();
112
113            for theme_dir in dirs {
114                // A directory counts as a theme if it carries cursors or says
115                // anything about itself. Indexing metadata-only themes matters
116                // because inheritance passes *through* cursor-less
117                // intermediates.
118                let has_cursors = theme_dir.join("cursors").exists();
119                let index_theme = theme_dir.join("index.theme");
120                let cursor_theme = theme_dir.join("cursor.theme");
121                if !has_cursors && !index_theme.exists() && !cursor_theme.exists() {
122                    continue;
123                }
124                let Some(name) = theme_dir.file_name().and_then(|n| n.to_str()) else {
125                    continue;
126                };
127                if name.is_empty() {
128                    continue;
129                }
130
131                let index = *by_name.entry(name.to_owned()).or_insert_with(|| {
132                    themes.push(Record {
133                        name: name.to_owned(),
134                        dirs: Vec::new(),
135                        inherits: Vec::new(),
136                    });
137                    themes.len() - 1
138                });
139                themes[index].dirs.push(theme_dir.clone());
140
141                // Parsed once per theme name, from the first directory that
142                // has something to say. Two copies disagreeing means the
143                // earlier search path wins, the same precedence as everywhere
144                // else here.
145                if themes[index].inherits.is_empty() {
146                    let mut inherits = parse_inherits(&cursor_theme);
147                    if inherits.is_empty() {
148                        inherits = parse_inherits(&index_theme);
149                    }
150                    themes[index].inherits = inherits;
151                }
152            }
153        }
154
155        if themes.is_empty() {
156            return Err(CursorError::NoThemes);
157        }
158
159        Ok(Self {
160            themes,
161            by_name,
162            search_paths: search_paths.to_vec(),
163            cache: RefCell::new(HashMap::new()),
164        })
165    }
166
167    /// The roots this index was built from.
168    #[must_use]
169    pub fn search_paths(&self) -> &[PathBuf] {
170        &self.search_paths
171    }
172
173    /// How many resolutions are memoized.
174    ///
175    /// Exists so the tests can check that a refused name never reaches the
176    /// cache, which is otherwise unobservable from outside.
177    #[cfg(test)]
178    pub(crate) fn cache_len(&self) -> usize {
179        self.cache.borrow().len()
180    }
181
182    /// The themes indexed, in discovery order.
183    #[must_use]
184    pub fn theme_names(&self) -> Vec<&str> {
185        self.themes.iter().map(|r| r.name.as_str()).collect()
186    }
187
188    /// Resolve a cursor name to a file.
189    ///
190    /// `preferred_theme` may be empty, in which case `$XCURSOR_THEME` is used,
191    /// then `default`. The `Inherits=` chain is followed breadth-first and
192    /// every alias of the name is tried in each theme.
193    ///
194    /// # Errors
195    ///
196    /// - [`CursorError::InvalidName`] for a name that is empty, `.`, `..`, or
197    ///   contains a path separator.
198    /// - [`CursorError::NotFound`] if no theme on the search path has it.
199    pub fn resolve(
200        &self,
201        cursor_name: &str,
202        preferred_theme: &str,
203    ) -> Result<ThemeResolution, CursorError> {
204        // The name is caller-controlled and gets joined onto a directory, so
205        // `../../etc/passwd` would compose into a real path. A compositor
206        // should sanitise upstream; the cost of refusing an ambiguous name
207        // here is one comparison. `..` is the classic escape, and a separator
208        // anywhere means the caller passed a path where an identifier belongs.
209        //
210        // Checked before the cache is touched, so a caller spamming bogus
211        // names cannot grow the map.
212        if cursor_name.is_empty()
213            || cursor_name == "."
214            || cursor_name == ".."
215            || cursor_name.contains('/')
216            || cursor_name.contains('\\')
217        {
218            return Err(CursorError::InvalidName);
219        }
220
221        let key = CacheKey {
222            cursor_name: cursor_name.to_owned(),
223            preferred_theme: preferred_theme.to_owned(),
224        };
225        if let Some(hit) = self.cache.borrow().get(&key) {
226            return hit.clone();
227        }
228
229        let outcome = self.walk(cursor_name, preferred_theme);
230        self.cache.borrow_mut().insert(key, outcome.clone());
231        outcome
232    }
233
234    /// The uncached resolve.
235    fn walk(
236        &self,
237        cursor_name: &str,
238        preferred_theme: &str,
239    ) -> Result<ThemeResolution, CursorError> {
240        let mut start = preferred_theme.to_owned();
241        if start.is_empty() {
242            start = env_string("XCURSOR_THEME");
243        }
244        if start.is_empty() {
245            // Nearly every distribution ships this as a symlink to whatever
246            // the active cursor pack is.
247            "default".clone_into(&mut start);
248        }
249
250        let mut chain: Vec<String> = Vec::new();
251        let mut visited: HashSet<String> = HashSet::new();
252
253        if let Some(found) = self.bfs(&start, cursor_name, &mut chain, &mut visited) {
254            return Ok(found);
255        }
256
257        // The named theme and its parents did not have it. Try the system
258        // `default` chain before falling back to discovery order: on most
259        // distributions `default` points at the active cursor pack, so this
260        // gets a sensible pointer rather than whichever theme sorted first.
261        // Skipped when the walk already covered it.
262        if start != "default"
263            && !visited.contains("default")
264            && let Some(found) = self.bfs("default", cursor_name, &mut chain, &mut visited)
265        {
266            return Ok(found);
267        }
268
269        // Last resort: every remaining theme in discovery order, which is
270        // search-path order, so user directories come before system ones.
271        for record in &self.themes {
272            if chain.iter().any(|seen| seen == &record.name) {
273                continue;
274            }
275            let found = record
276                .dirs
277                .iter()
278                .find_map(|dir| find_in_cursors_dir(&dir.join("cursors"), cursor_name));
279            chain.push(record.name.clone());
280            if let Some(source) = found {
281                return Ok(ThemeResolution {
282                    theme_name: record.name.clone(),
283                    source,
284                    chain,
285                });
286            }
287        }
288
289        Err(CursorError::NotFound)
290    }
291
292    /// Breadth-first walk of the inherits graph from `root`, checking each
293    /// theme for the cursor as it is reached.
294    ///
295    /// `chain` and `visited` are threaded through so a second root can be
296    /// walked onto the same state without redoing work or re-reporting themes.
297    fn bfs(
298        &self,
299        root: &str,
300        cursor_name: &str,
301        chain: &mut Vec<String>,
302        visited: &mut HashSet<String>,
303    ) -> Option<ThemeResolution> {
304        let mut queue: std::collections::VecDeque<String> =
305            std::collections::VecDeque::from([root.to_owned()]);
306
307        while let Some(current) = queue.pop_front() {
308            if !visited.insert(current.clone()) {
309                continue;
310            }
311            chain.push(current.clone());
312
313            let Some(&index) = self.by_name.get(&current) else {
314                // A theme named by an Inherits= that is not installed. Still
315                // recorded in the chain: "we looked for it and it was not
316                // there" is exactly what the diagnostics are for.
317                continue;
318            };
319            let record = &self.themes[index];
320            for parent in &record.inherits {
321                queue.push_back(parent.clone());
322            }
323            for dir in &record.dirs {
324                if let Some(source) = find_in_cursors_dir(&dir.join("cursors"), cursor_name) {
325                    return Some(ThemeResolution {
326                        theme_name: current,
327                        source,
328                        chain: std::mem::take(chain),
329                    });
330                }
331            }
332        }
333        None
334    }
335}
336
337/// The first alias of `cursor_name` that exists in `cursors_dir`.
338fn find_in_cursors_dir(cursors_dir: &Path, cursor_name: &str) -> Option<PathBuf> {
339    let mut found = None;
340    alias::for_each(cursor_name, |candidate| {
341        if found.is_some() {
342            return;
343        }
344        let path = cursors_dir.join(candidate);
345        if path.exists() {
346            found = Some(path);
347        }
348    });
349    found
350}
351
352/// An environment variable as a `String`, empty when unset or not UTF-8.
353fn env_string(name: &str) -> String {
354    std::env::var(name).unwrap_or_default()
355}
356
357/// Split a `PATH`-style value. Empty entries are dropped, so a leading or
358/// trailing separator does not contribute an empty path.
359fn split_path_list(value: &str) -> Vec<PathBuf> {
360    value
361        .split(':')
362        .filter(|part| !part.is_empty())
363        .map(PathBuf::from)
364        .collect()
365}
366
367/// The default cursor search path.
368///
369/// Mirrors libxcursor: `XCURSOR_PATH` wins outright; otherwise the user and
370/// system XDG roots with `/icons` appended, plus `/usr/share/pixmaps`, which
371/// themes still drop cursors into.
372#[must_use]
373pub fn default_search_paths() -> Vec<PathBuf> {
374    let explicit = env_string("XCURSOR_PATH");
375    if !explicit.is_empty() {
376        return split_path_list(&explicit);
377    }
378
379    let mut out = Vec::new();
380    let home = env_string("HOME");
381    if !home.is_empty() {
382        out.push(PathBuf::from(&home).join(".icons"));
383    }
384
385    let data_home = env_string("XDG_DATA_HOME");
386    if data_home.is_empty() {
387        if !home.is_empty() {
388            out.push(
389                PathBuf::from(&home)
390                    .join(".local")
391                    .join("share")
392                    .join("icons"),
393            );
394        }
395    } else {
396        for path in split_path_list(&data_home) {
397            out.push(path.join("icons"));
398        }
399    }
400
401    let data_dirs = env_string("XDG_DATA_DIRS");
402    let dirs = if data_dirs.is_empty() {
403        split_path_list("/usr/local/share:/usr/share")
404    } else {
405        split_path_list(&data_dirs)
406    };
407    for path in dirs {
408        out.push(path.join("icons"));
409    }
410
411    out.push(PathBuf::from("/usr/share/pixmaps"));
412    out
413}
414
415/// Read `Inherits=` out of an `index.theme`-style file.
416///
417/// Only that one key is needed, so this is a few lines of ini parsing rather
418/// than a dependency. Returns empty for a missing or unreadable file, which is
419/// the same as a theme that inherits nothing -- there is no useful difference
420/// to a caller.
421fn parse_inherits(path: &Path) -> Vec<String> {
422    let Ok(text) = std::fs::read_to_string(path) else {
423        return Vec::new();
424    };
425
426    let mut in_icon_theme = false;
427    for line in text.lines() {
428        let line = line.trim();
429        if line.is_empty() || line.starts_with('#') {
430            continue;
431        }
432        if line.starts_with('[') {
433            // A new section. If the Icon Theme section is over, so is the
434            // search: a later section's `Inherits` is not ours.
435            if in_icon_theme {
436                break;
437            }
438            in_icon_theme = line == "[Icon Theme]";
439            continue;
440        }
441        if !in_icon_theme {
442            continue;
443        }
444        let Some(rest) = line.strip_prefix("Inherits") else {
445            continue;
446        };
447        let Some(rest) = rest.trim_start().strip_prefix('=') else {
448            continue;
449        };
450        return rest
451            // The spec says comma; themes in the wild also use semicolon. No
452            // known theme mixes them in one list.
453            .split([',', ';'])
454            .map(str::trim)
455            .filter(|parent| !parent.is_empty())
456            .map(str::to_owned)
457            .collect();
458    }
459    Vec::new()
460}