1use std::collections::HashSet;
4use std::env;
5use std::path::{Path, PathBuf};
6
7pub mod parser;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum CursorFormat {
13 XCursor,
15 Scalable,
17}
18
19impl CursorFormat {
20 fn subdir(self) -> &'static str {
22 match self {
23 CursorFormat::XCursor => "cursors",
24 CursorFormat::Scalable => "cursors_scalable",
25 }
26 }
27
28 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#[derive(Debug, PartialEq, Eq, Clone)]
39pub struct CursorTheme {
40 theme: CursorThemeIml,
41 search_paths: Vec<PathBuf>,
43}
44
45impl CursorTheme {
46 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 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 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 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 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 name: String,
133 data: Vec<(PathBuf, Option<String>)>,
136}
137
138impl CursorThemeIml {
139 fn load(name: &str, search_paths: &[PathBuf]) -> Self {
141 let mut data = Vec::new();
142
143 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 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 walked_themes.insert(self.name.clone());
188
189 for data in &self.data {
190 let inherits = match data.1.as_ref() {
192 Some(inherits) => inherits,
193 None => continue,
194 };
195
196 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
237fn 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 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
295fn 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
315fn 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
325fn 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 if !line.starts_with(PATTERN) {
335 continue;
336 }
337
338 let mut chars = line.get(PATTERN.len()..).unwrap().trim_start().chars();
340
341 if Some('=') != chars.next() {
343 continue;
344 }
345
346 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 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 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 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}