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