1use std::collections::HashSet;
4use std::env;
5use std::path::{Path, PathBuf};
6
7pub mod parser;
9
10#[derive(Debug, PartialEq, Eq, Clone)]
12pub struct CursorTheme {
13 theme: CursorThemeIml,
14 search_paths: Vec<PathBuf>,
16}
17
18impl CursorTheme {
19 pub fn load(name: &str) -> Self {
23 let search_paths = theme_search_paths(SearchPathsEnvironment::get());
24
25 let theme = CursorThemeIml::load(name, &search_paths);
26
27 CursorTheme {
28 theme,
29 search_paths,
30 }
31 }
32
33 pub fn load_icon(&self, icon_name: &str) -> Option<PathBuf> {
38 let mut walked_themes = HashSet::new();
39
40 self.theme
41 .load_icon_with_depth(icon_name, &self.search_paths, &mut walked_themes)
42 .map(|(pathbuf, _)| pathbuf)
43 }
44
45 pub fn load_icon_with_depth(&self, icon_name: &str) -> Option<(PathBuf, usize)> {
53 let mut walked_themes = HashSet::new();
54
55 self.theme
56 .load_icon_with_depth(icon_name, &self.search_paths, &mut walked_themes)
57 }
58}
59
60#[derive(Debug, PartialEq, Eq, Clone)]
61struct CursorThemeIml {
62 name: String,
64 data: Vec<(PathBuf, Option<String>)>,
67}
68
69impl CursorThemeIml {
70 fn load(name: &str, search_paths: &[PathBuf]) -> Self {
72 let mut data = Vec::new();
73
74 for mut path in search_paths.iter().cloned() {
76 path.push(name);
77 if path.is_dir() {
78 let data_dir = path.clone();
79
80 path.push("index.theme");
81 let inherits = if let Some(inherits) = theme_inherits(&path) {
82 Some(inherits)
83 } else if name != "default" {
84 Some(String::from("default"))
85 } else {
86 None
87 };
88
89 data.push((data_dir, inherits));
90 }
91 }
92
93 CursorThemeIml {
94 name: name.to_owned(),
95 data,
96 }
97 }
98
99 fn load_icon_with_depth(
101 &self,
102 icon_name: &str,
103 search_paths: &[PathBuf],
104 walked_themes: &mut HashSet<String>,
105 ) -> Option<(PathBuf, usize)> {
106 for data in &self.data {
107 let mut icon_path = data.0.clone();
108 icon_path.push("cursors");
109 icon_path.push(icon_name);
110 if icon_path.is_file() {
111 return Some((icon_path, 0));
112 }
113 }
114
115 walked_themes.insert(self.name.clone());
118
119 for data in &self.data {
120 let inherits = match data.1.as_ref() {
122 Some(inherits) => inherits,
123 None => continue,
124 };
125
126 if walked_themes.contains(inherits) {
128 continue;
129 }
130
131 let inherited_theme = CursorThemeIml::load(inherits, search_paths);
132
133 match inherited_theme.load_icon_with_depth(icon_name, search_paths, walked_themes) {
134 Some((icon_path, depth)) => return Some((icon_path, depth + 1)),
135 None => continue,
136 }
137 }
138
139 None
140 }
141}
142
143#[derive(Default)]
144struct SearchPathsEnvironment {
145 home: Option<String>,
146 xcursor_path: Option<String>,
147 xdg_data_home: Option<String>,
148 xdg_data_dirs: Option<String>,
149}
150
151impl SearchPathsEnvironment {
152 fn get() -> Self {
153 SearchPathsEnvironment {
154 home: env::var("HOME").ok().filter(|x| !x.is_empty()),
155 xcursor_path: env::var("XCURSOR_PATH").ok().filter(|x| !x.is_empty()),
156 xdg_data_home: env::var("XDG_DATA_HOME").ok().filter(|x| !x.is_empty()),
157 xdg_data_dirs: env::var("XDG_DATA_DIRS").ok().filter(|x| !x.is_empty()),
158 }
159 }
160}
161
162fn theme_search_paths(environment: SearchPathsEnvironment) -> Vec<PathBuf> {
165 let home_dir = environment
166 .home
167 .as_ref()
168 .map(|home| Path::new(home.as_str()));
169
170 if let Some(xcursor_path) = environment.xcursor_path {
171 return xcursor_path
172 .split(':')
173 .flat_map(|entry| {
174 if entry.is_empty() {
175 return None;
176 }
177 expand_home_dir(PathBuf::from(entry), home_dir)
178 })
179 .collect();
180 }
181
182 let mut paths = Vec::new();
184
185 if let Some(xdg_data_home) = environment.xdg_data_home {
186 paths.extend(expand_home_dir(PathBuf::from(xdg_data_home), home_dir));
187 } else if let Some(home_dir) = home_dir {
188 paths.push(home_dir.join(".local/share/icons"))
189 }
190
191 if let Some(home_dir) = home_dir {
192 paths.push(home_dir.join(".icons"));
193 }
194
195 if let Some(xdg_data_dirs) = environment.xdg_data_dirs {
196 paths.extend(xdg_data_dirs.split(':').flat_map(|entry| {
197 if entry.is_empty() {
198 return None;
199 }
200 let mut entry = expand_home_dir(PathBuf::from(entry), home_dir)?;
201 entry.push("icons");
202 Some(entry)
203 }))
204 } else {
205 paths.push(PathBuf::from("/usr/local/share/icons"));
206 paths.push(PathBuf::from("/usr/share/icons"));
207 }
208
209 paths.push(PathBuf::from("/usr/share/pixmaps"));
210
211 if let Some(home_dir) = home_dir {
212 paths.push(home_dir.join(".cursors"));
213 }
214
215 paths.push(PathBuf::from("/usr/share/cursors/xorg-x11"));
216
217 paths
218}
219
220fn expand_home_dir(path: PathBuf, home_dir: Option<&Path>) -> Option<PathBuf> {
223 let mut components = path.iter();
224 if let Some(first_component) = components.next() {
225 if first_component == "~" {
226 if let Some(home_dir) = home_dir {
227 let mut path = home_dir.to_path_buf();
228 for component in components {
229 path.push(component);
230 }
231 return Some(path);
232 } else {
233 return None;
234 }
235 }
236 }
237 Some(path)
238}
239
240fn theme_inherits(file_path: &Path) -> Option<String> {
245 let content = std::fs::read_to_string(file_path).ok()?;
246
247 parse_theme(&content)
248}
249
250fn parse_theme(content: &str) -> Option<String> {
252 const PATTERN: &str = "Inherits";
253
254 let is_xcursor_space_or_separator =
255 |&ch: &char| -> bool { ch.is_whitespace() || ch == ';' || ch == ',' };
256
257 for line in content.lines() {
258 if !line.starts_with(PATTERN) {
260 continue;
261 }
262
263 let mut chars = line.get(PATTERN.len()..).unwrap().trim_start().chars();
265
266 if Some('=') != chars.next() {
268 continue;
269 }
270
271 let result: String = chars
273 .skip_while(is_xcursor_space_or_separator)
274 .take_while(|ch| !is_xcursor_space_or_separator(ch))
275 .collect();
276
277 if !result.is_empty() {
278 return Some(result);
279 }
280 }
281
282 None
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::path::{Path, PathBuf};
289
290 #[test]
291 fn test_parse_theme() {
292 let theme_name = String::from("XCURSOR_RS");
293
294 let theme = format!("Inherits={}", theme_name.clone());
295
296 assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
297
298 let theme = format!(" Inherits={}", theme_name.clone());
299
300 assert_eq!(parse_theme(&theme), None);
301
302 let theme = format!(
303 "[THEME name]\nInherits = ,;\t\t{};;;;Tail\n\n",
304 theme_name.clone()
305 );
306
307 assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
308
309 let theme = format!("Inherits;=;{}", theme_name.clone());
310
311 assert_eq!(parse_theme(&theme), None);
312
313 let theme = format!("Inherits = {}\n\nInherits=OtherTheme", theme_name.clone());
314
315 assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
316
317 let theme = format!(
318 "Inherits = ;;\nSome\tgarbage\nInherits={}",
319 theme_name.clone()
320 );
321
322 assert_eq!(parse_theme(&theme), Some(theme_name.clone()));
323 }
324
325 #[test]
326 fn test_expand_home_dir() {
327 let home = Path::new("/home/user");
328
329 let result = expand_home_dir("~".into(), Some(home));
330 assert_eq!(result, Some("/home/user".into()));
331
332 let result = expand_home_dir("~/.icons".into(), Some(home));
333 assert_eq!(result, Some("/home/user/.icons".into()));
334
335 let result = expand_home_dir("~/.local/share/icons".into(), Some(home));
336 assert_eq!(result, Some("/home/user/.local/share/icons".into()));
337
338 let result = expand_home_dir("~/.icons".into(), None);
339 assert_eq!(result, None);
340
341 let path: PathBuf = "/usr/share/icons".into();
342 let result = expand_home_dir(path.clone(), Some(home));
343 assert_eq!(result, Some(path));
344
345 let path: PathBuf = "".into();
346 let result = expand_home_dir(path.clone(), Some(home));
347 assert_eq!(result, Some(path));
348
349 let path: PathBuf = "/some/path/~/icons".into();
351 let result = expand_home_dir(path.clone(), Some(home));
352 assert_eq!(result, Some(path));
353 }
354
355 #[test]
356 fn test_theme_search_paths() {
357 assert_eq!(
358 theme_search_paths(SearchPathsEnvironment {
359 home: Some("/home/user".to_string()),
360 xdg_data_home: Some("/home/user/.data".to_string()),
361 xdg_data_dirs: Some("/opt/share::/usr/local/share:~/custom/share".to_string()),
362 ..Default::default()
363 }),
364 vec![
365 PathBuf::from("/home/user/.data"),
366 PathBuf::from("/home/user/.icons"),
367 PathBuf::from("/opt/share/icons"),
368 PathBuf::from("/usr/local/share/icons"),
369 PathBuf::from("/home/user/custom/share/icons"),
370 PathBuf::from("/usr/share/pixmaps"),
371 PathBuf::from("/home/user/.cursors"),
372 PathBuf::from("/usr/share/cursors/xorg-x11"),
373 ]
374 );
375
376 assert_eq!(
378 theme_search_paths(SearchPathsEnvironment {
379 home: Some("/home/user".to_string()),
380 xcursor_path: Some("~/custom/xcursor/icons:/absolute-path/icons".to_string()),
381 ..Default::default()
382 }),
383 vec![
384 PathBuf::from("/home/user/custom/xcursor/icons"),
385 PathBuf::from("/absolute-path/icons")
386 ]
387 );
388
389 assert_eq!(
391 theme_search_paths(SearchPathsEnvironment {
392 xdg_data_home: Some("~/.data".to_string()),
393 ..Default::default()
394 }),
395 vec![
396 PathBuf::from("/usr/local/share/icons"),
397 PathBuf::from("/usr/share/icons"),
398 PathBuf::from("/usr/share/pixmaps"),
399 PathBuf::from("/usr/share/cursors/xorg-x11"),
400 ]
401 );
402 }
403}