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