StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use std::{env, path::PathBuf}; |
| 2 | use tini::Ini; |
| 3 | |
| 4 | static CURSOR_DIR_NAME: &'static &str = &"cursors"; |
| 5 | static CURSOR_INDEX_FILE_NAME: &'static &str = &"index.theme"; |
| 6 | static THEME_FILE_CURSOR_SECTION: &'static &str = &"Icon Theme"; |
| 7 | static THEME_FILE_INHERITS_KEY: &'static &str = &"Inherits"; |
| 8 | |
| 9 | static ENV_DATA_DIRS: &'static &str = &"XDG_DATA_DIRS"; |
| 10 | static ENV_CURSOR_THEME: &'static &str = &"XCURSOR_THEME"; |
| 11 | |
| 12 | static DEFAULT_THEME: &'static &str = &"default"; |
| 13 | static KNOWN_THEMES: &[&str] = &["Yaru", "Adwaita"]; |
| 14 | |
| 15 | pub fn ensure_cursor_theme() { |
| 16 | // If the XCURSOR_THEME value is explicitly set, |
| 17 | // then we do not want to modify the user's environment |
| 18 | if env::var(ENV_CURSOR_THEME).is_ok() { |
| 19 | return; |
| 20 | } |
| 21 | |
| 22 | let crawler = CursorThemeCrawler::new(); |
| 23 | |
| 24 | if let Some(theme) = crawler.determine_cursor_theme() { |
| 25 | // winit and it's dependencies will automatically check for |
| 26 | // the default theme, so we do not need to mess with the |
| 27 | // env var here. |
| 28 | if theme != *DEFAULT_THEME { |
| 29 | env::set_var(ENV_CURSOR_THEME, theme); |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | struct CursorThemeCrawler { |
| 35 | /// Directories to search when looking for a cursor theme. |
| 36 | /// Directories are searched in vec order from first to last. |
| 37 | /// However, because themes can reference other themes, it is |
| 38 | /// possible for search results to traverse multiple directories. |
| 39 | /// For example, a default theme can be found in directories[1] |
| 40 | /// that inherits from a theme in directories[3], which itself |
| 41 | /// inherits from a theme in directories[0] |
| 42 | directories: Vec<PathBuf>, |
| 43 | } |
| 44 | |
| 45 | fn non_empty_var(name: &str) -> Option<String> { |
| 46 | env::var(name).ok().filter(|val| !val.is_empty()) |
| 47 | } |
| 48 | |
| 49 | impl CursorThemeCrawler { |
| 50 | pub fn new() -> Self { |
| 51 | // Per https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#directory_layout, |
| 52 | // we search: |
| 53 | // - $HOME/.icons (for backwards compatibility) |
| 54 | // - $XDG_DATA_HOME/icons (technically this should be part of XDG_DATA_DIRS, but we add it in here) |
| 55 | // - Defaults to $HOME/.local/share |
| 56 | // - $XDG_DATA_DIRS/icons |
| 57 | // - Defaults to /usr/local/share/:/usr/share/ |
| 58 | // - /usr/share/pixmaps |
| 59 | let mut directories = vec![]; |
| 60 | |
| 61 | let xdg_data_dirs = non_empty_var(ENV_DATA_DIRS) |
| 62 | .or_else(|| Some("/usr/local/share/:/usr/share/".to_string())); |
| 63 | |
| 64 | if let Some(home) = dirs::home_dir() { |
| 65 | directories.push(home.join(".icons")); |
| 66 | } |
| 67 | if let Some(xdg_data_home) = dirs::data_dir() { |
| 68 | directories.push(xdg_data_home.join("icons")); |
| 69 | } |
| 70 | if let Some(xdg_data_dirs) = xdg_data_dirs { |
| 71 | for dir in xdg_data_dirs.split(':') { |
| 72 | if !dir.is_empty() { |
| 73 | directories.push(PathBuf::from(dir).join("icons")); |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | directories.push(PathBuf::from("/usr/share/pixmaps")); |
| 78 | Self { directories } |
| 79 | } |
| 80 | |
| 81 | /// First checks to see if there is a default cursor theme set. |
| 82 | /// If there is no default set, we check a list of known themes. |
| 83 | /// The first theme to be confirmed exist is returned, else None |
| 84 | /// is returned. |
| 85 | fn determine_cursor_theme(&self) -> Option<String> { |
| 86 | if self.check_cursor_theme(DEFAULT_THEME) { |
| 87 | return Some(DEFAULT_THEME.to_string()); |
| 88 | } |
| 89 | |
| 90 | for theme in KNOWN_THEMES { |
| 91 | if self.check_cursor_theme(theme) { |
| 92 | return Some((*theme).to_string()); |
| 93 | } |
| 94 | } |
| 95 | None |
| 96 | } |
| 97 | |
| 98 | /// Returns true if an icon theme exists and has a `cursors/` |
| 99 | /// folder, indicating that the cursors for that theme are installed. |
| 100 | /// Per the specification, an icon theme can exist along multiple |
| 101 | /// directories. As long as at least one of those directories |
| 102 | /// contains the `cursors/` subdir, we consider it valid |
| 103 | fn check_cursor_theme_installed(&self, theme: &str) -> bool { |
| 104 | for dir in &self.directories { |
| 105 | if dir.join(theme).join(CURSOR_DIR_NAME).exists() { |
| 106 | return true; |
| 107 | } |
| 108 | } |
| 109 | false |
| 110 | } |
| 111 | |
| 112 | /// Checks that a given icon theme is a valid cursor theme. |
| 113 | /// |
| 114 | /// When we check a cursor theme, we verify that either: |
| 115 | /// a. The icon theme has a cursors/ folder |
| 116 | /// b. The icon theme inherits from an existing cursor theme. |
| 117 | /// |
| 118 | /// This can cause us to traverse multiple themes as part of our validation. |
| 119 | /// we do this verification to handle cases like the `adwaita-icon-theme` |
| 120 | /// deb packages, which sets Adwaita to the default icon theme without |
| 121 | /// installing a cursor theme. |
| 122 | fn check_cursor_theme(&self, root_theme: &str) -> bool { |
| 123 | let mut visited = std::collections::HashSet::from([root_theme.to_string()]); |
| 124 | let mut pending = std::collections::VecDeque::from([root_theme.to_string()]); |
| 125 | |
| 126 | while let Some(theme) = pending.pop_front() { |
| 127 | if self.check_cursor_theme_installed(&theme) { |
| 128 | return true; |
| 129 | } |
| 130 | |
| 131 | // Per the spec, the **first** index.theme found when traversing |
| 132 | // the directories is used |
| 133 | let inherited_themes = &self |
| 134 | .directories |
| 135 | .iter() |
| 136 | .filter_map(|index_dir| { |
| 137 | let index_path = index_dir.join(&theme).join(CURSOR_INDEX_FILE_NAME); |
| 138 | if let Ok(theme_file) = Ini::from_file(&index_path) { |
| 139 | theme_file.get_vec_with_sep::<String>( |
| 140 | THEME_FILE_CURSOR_SECTION, |
| 141 | THEME_FILE_INHERITS_KEY, |
| 142 | ",", |
| 143 | ) |
| 144 | } else { |
| 145 | None |
| 146 | } |
| 147 | }) |
| 148 | .next(); |
| 149 | |
| 150 | if let Some(inherited_themes) = inherited_themes { |
| 151 | for new_theme in inherited_themes { |
| 152 | if !visited.contains(new_theme) { |
| 153 | visited.insert(new_theme.clone()); |
| 154 | pending.push_back(new_theme.clone()); |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | false |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | #[cfg(test)] |
| 165 | #[path = "cursor_theme_tests.rs"] |
| 166 | mod tests; |
| 167 |