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(¤t) 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}