freedesktop_icon/
lib.rs

1use ini::Ini;
2use std::{path::PathBuf, sync::LazyLock};
3
4static CURRENT_ICON_THEME: LazyLock<IconTheme> = LazyLock::new(IconTheme::current);
5
6#[derive(Debug, Clone)]
7pub struct IconTheme {
8    name: String,
9    path: PathBuf,
10    config: Ini,
11}
12
13impl IconTheme {
14    pub fn name(&self) -> &str {
15        &self.name
16    }
17
18    pub fn path(&self) -> PathBuf {
19        self.path.clone()
20    }
21
22    fn config(&self) -> &Ini {
23        &self.config
24    }
25
26    pub fn config_value<S: Into<String>, A: AsRef<str>>(
27        &self,
28        section_name: S,
29        key: A,
30    ) -> Option<String> {
31        let cfg = self.config();
32        let section = cfg.section(Some(section_name))?;
33        let value = section.get(key)?;
34
35        Some(value.to_string())
36    }
37
38    pub fn inherits(&self) -> Vec<String> {
39        let Some(inherits) = &self.config_value("Icon Theme", "Inherits") else {
40            return Vec::new();
41        };
42
43        inherits.split(",").map(String::from).collect()
44    }
45
46    // Per the spec, we will use $XDG_DATA_HOME/icons/[theme name]
47    // as an "overlay" if it exists. Meaning that if a theme
48    // has dir /some/system/install/path/[theme]/48x48
49    // we will look in $XDG_DATA_HOME/icons/[theme]/48x48
50    // if it exists before we look in the system path.
51    pub fn icon_dirs(&self, size: u32, scale: u8) -> Vec<PathBuf> {
52        let Some(dir_str) = &self.config_value("Icon Theme", "Directories") else {
53            return Vec::new();
54        };
55
56        let dirs: Vec<String> = dir_str.split(",").map(String::from).collect();
57        let overlay_dir = freedesktop_core::xdg_data_home()
58            .join("icons")
59            .join(&self.name);
60
61        let mut paths: Vec<PathBuf> = Vec::new();
62
63        for d in &dirs {
64            let dir_size = &self
65                .config_value(d, "Size")
66                .map(|s| s.parse::<u32>().unwrap_or(0))
67                .unwrap_or(0);
68
69            let dir_scale = match &self.config_value(d, "Scale") {
70                Some(s) => s.parse::<u8>().unwrap_or(1),
71                None => 1,
72            };
73
74            if (dir_size == &size) && (dir_scale == scale) {
75                let overlay = overlay_dir.join(d);
76                if overlay.exists() {
77                    paths.push(overlay);
78                }
79
80                paths.push(self.path.join(d));
81            }
82        }
83
84        paths
85    }
86
87    pub fn default_size(&self) -> Option<u32> {
88        let Some(size_str) = &self.config_value("Icon Theme", "DesktopDefault") else {
89            return None;
90        };
91
92        size_str.parse::<u32>().ok()
93    }
94
95    // Internal function for getting an icon from the
96    // theme. The public get() function actually
97    // traverses the inheritance chain.
98    fn get_icon(&self, icon_name: &str, size: u32, scale: u8) -> Option<PathBuf> {
99        let filenames = [
100            format!("{}.{}", icon_name, "svg"),
101            format!("{}.{}", icon_name, "png"),
102            format!("{}.{}", icon_name, "xpm"),
103        ];
104
105        for d in &self.icon_dirs(size, scale) {
106            for f in &filenames {
107                let icon_path = d.join(f);
108                if icon_path.exists() {
109                    return Some(icon_path);
110                }
111            }
112        }
113
114        None
115    }
116
117    // First option in the process: We go through the current theme
118    // and its inherited themes all the way down to see if we
119    // can find the icon.
120    fn get_through_inheritance(&self, icon_name: &str, size: u32, scale: u8) -> Option<PathBuf> {
121        if let Some(icon_path) = &self.get_icon(icon_name, size, scale) {
122            return Some(icon_path.to_owned());
123        }
124
125        // If we don't find it in the current theme, start recursing
126        // into the inheritance chain loading the themes lazily
127        for theme_name in &self.inherits() {
128            let Some(theme) = IconTheme::from_name(theme_name) else {
129                continue;
130            };
131
132            match theme.get_through_inheritance(icon_name, size, scale) {
133                Some(icon_path) => return Some(icon_path),
134                None => continue,
135            }
136        }
137
138        None
139    }
140
141    /// Get an icon by name following the freedesktop icon theme specification
142    /// Searches through the current theme and inherited themes for the icon
143    pub fn get(&self, icon_name: &str) -> Option<PathBuf> {
144        let size = self.default_size().unwrap_or(48);
145        let scale: u8 = 1;
146
147        if let Some(path) = &self.get_through_inheritance(icon_name, size, scale) {
148            return Some(path.to_owned());
149        }
150
151        // Pixmaps are a last resort
152        Pixmap::get(icon_name)
153    }
154}
155
156impl IconTheme {
157    /// According to the spec:
158    /// First search $XDG_DATA_HOME/icons/[theme name]
159    /// If not found, search $XDG_DATA_DIRS in order for
160    /// [dir name]/icons/[theme name]
161    /// The order of $XDG_DATA_DIRS needs to be respected, as the
162    /// first hit counts as the "canonical path" of the theme.
163    /// We will also check that an index.theme exists at the path
164    /// since any valid theme must have this file.
165    pub fn from_name<S: Into<String>>(name: S) -> Option<IconTheme> {
166        let name: String = name.into();
167        let xdg_home_path = freedesktop_core::xdg_data_home().join("icons").join(&name);
168
169        if xdg_home_path.exists() {
170            let config_path = xdg_home_path.join("index.theme");
171            if config_path.exists() {
172                let config = Ini::load_from_file(&config_path).unwrap_or_else(|_| Ini::new());
173                return Some(IconTheme {
174                    name,
175                    path: xdg_home_path,
176                    config,
177                });
178            }
179        }
180
181        for data_dir in freedesktop_core::xdg_data_dirs() {
182            let theme_path = data_dir.join("icons").join(&name);
183
184            if theme_path.exists() {
185                let config_path = theme_path.join("index.theme");
186                if config_path.exists() {
187                    let config = Ini::load_from_file(&config_path).unwrap_or_else(|_| Ini::new());
188                    return Some(IconTheme {
189                        name,
190                        path: theme_path,
191                        config,
192                    });
193                }
194            }
195        }
196
197        None
198    }
199
200    pub fn current() -> IconTheme {
201        let home = std::env::var("HOME").expect("$HOME variable not set.");
202        let config_path = freedesktop_core::xdg_config_home();
203        let settings_paths = [
204            PathBuf::from(&config_path)
205                .join("gtk-4.0")
206                .join("settings.ini"),
207            PathBuf::from(&config_path)
208                .join("gtk-3.0")
209                .join("settings.ini"),
210            PathBuf::from(&home).join("gtk-4.0").join("settings.ini"),
211            PathBuf::from(&home).join("gtk-3.0").join("settings.ini"),
212        ];
213        let fallback_theme = || {
214            IconTheme::from_name("hicolor").expect("The hicolor theme is not present. This is a required fallback theme and must be installed")
215        };
216
217        for p in &settings_paths {
218            if !p.exists() {
219                continue;
220            }
221
222            let Ok(conf) = Ini::load_from_file(p) else {
223                continue;
224            };
225
226            if let Some(section) = conf.section(Some("Settings")) {
227                if let Some(theme) = section.get("gtk-icon-theme-name") {
228                    return IconTheme::from_name(theme).unwrap_or_else(fallback_theme);
229                } else {
230                    continue;
231                }
232            }
233        }
234
235        fallback_theme()
236    }
237}
238
239/// Pixmaps are a fallback when an icon is not found
240/// in a theme or any of it's inherited themes.
241pub struct Pixmap;
242
243impl Pixmap {
244    pub fn get(icon_name: &str) -> Option<PathBuf> {
245        let pixmap_paths = freedesktop_core::xdg_data_dirs()
246            .into_iter()
247            .map(|p| p.join("pixmaps"))
248            .filter(|p| p.exists());
249
250        let filenames = [
251            format!("{}.{}", icon_name, "svg"),
252            format!("{}.{}", icon_name, "png"),
253            format!("{}.{}", icon_name, "xpm"),
254        ];
255
256        for d in pixmap_paths {
257            for f in &filenames {
258                let icon_path = d.join(f);
259                if icon_path.exists() {
260                    return Some(icon_path);
261                }
262            }
263        }
264
265        None
266    }
267}
268
269/// Convenience function that will:
270/// Get the current icon theme from IconTheme::current()
271/// Call theme.get() which will get the icon for
272/// the default size and scale set for the theme.
273/// IconTheme::current() will be cached using LazyLock
274/// so multiple calls to this function do not incurr
275/// a performance penalty
276pub fn get_icon(name: &str) -> Option<PathBuf> {
277    CURRENT_ICON_THEME.get(name)
278}