cosmic_freedesktop_icons/
lib.rs

1//! # freedesktop-icons
2//!
3//! This crate provides a [freedesktop icon](https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#implementation_notes) lookup implementation.
4//!
5//! It exposes a single lookup function to find icons based on their `name`, `theme`, `size` and `scale`.
6//!
7//! ## Example
8//!
9//! **Simple lookup:**
10//!
11//! The following snippet get an icon from the default 'hicolor' theme
12//! with the default scale (`1`) and the default size (`24`).
13//!
14//! ```rust
15//! # fn main() {
16//! use cosmic_freedesktop_icons::lookup;
17//!
18//! let icon = lookup("firefox").find();
19//! # }
20//!```
21//!
22//! **Complex lookup:**
23//!
24//! If you have specific requirements for your lookup you can use the provided builder functions:
25//!
26//! ```rust
27//! # fn main() {
28//! use cosmic_freedesktop_icons::lookup;
29//!
30//! let icon = lookup("firefox")
31//!     .with_size(48)
32//!     .with_scale(2)
33//!     .with_theme("Arc")
34//!     .find();
35//! # }
36//!```
37//! **Cache:**
38//!
39//! If your application is going to repeat the same icon lookups multiple times
40//! you can use the internal cache to improve performance.
41//!
42//! ```rust
43//! # fn main() {
44//! use cosmic_freedesktop_icons::lookup;
45//!
46//! let icon = lookup("firefox")
47//!     .with_size(48)
48//!     .with_scale(2)
49//!     .with_theme("Arc")
50//!     .with_cache()
51//!     .find();
52//! # }
53//! ```
54use theme::BASE_PATHS;
55
56use crate::cache::{CacheEntry, CACHE};
57use crate::theme::{try_build_icon_path, THEMES};
58use std::io::BufRead;
59use std::path::PathBuf;
60use std::time::Instant;
61
62mod cache;
63mod theme;
64
65/// Return the list of installed themes on the system
66///
67/// ## Example
68/// ```rust,no_run
69/// # fn main() {
70/// use cosmic_freedesktop_icons::list_themes;
71///
72/// let themes: Vec<&str> = list_themes();
73///
74/// assert_eq!(themes, vec![
75///     "Adwaita", "Arc", "Breeze Light", "HighContrast", "Papirus", "Papirus-Dark",
76///     "Papirus-Light", "Breeze", "Breeze Dark", "Breeze", "ePapirus", "ePapirus-Dark", "Hicolor"
77/// ])
78/// # }
79pub fn list_themes() -> Vec<String> {
80    let mut themes = THEMES
81        .values()
82        .flatten()
83        .map(|path| &path.index)
84        .filter_map(|index| {
85            let file = std::fs::File::open(index).ok()?;
86            let mut reader = std::io::BufReader::new(file);
87
88            let mut line = String::new();
89            while let Ok(read) = reader.read_line(&mut line) {
90                if read == 0 {
91                    break;
92                }
93
94                if let Some(name) = line.strip_prefix("Name=") {
95                    return Some(name.trim().to_owned());
96                }
97
98                line.clear();
99            }
100
101            None
102        })
103        .collect::<Vec<_>>();
104    themes.dedup();
105    themes
106}
107
108/// Return the default GTK theme if set.
109///
110/// ## Example
111/// ```rust, no_run
112/// use freedesktop_icons::default_theme_gtk;
113///
114/// let theme = default_theme_gtk();
115///
116/// assert_eq!(Some("Adwaita"), theme);
117/// ```
118pub fn default_theme_gtk() -> Option<String> {
119    // Calling gsettings is the simplest way to retrieve the default icon theme without adding
120    // GTK as a dependency. There seems to be several ways to set the default GTK theme
121    // including a file in XDG_CONFIG_HOME as well as an env var. Gsettings is the most
122    // straightforward method.
123    let gsettings = std::process::Command::new("gsettings")
124        .args(["get", "org.gnome.desktop.interface", "icon-theme"])
125        .output()
126        .ok()?;
127
128    // Only return the theme if it's in the cache.
129    if gsettings.status.success() {
130        let name = String::from_utf8(gsettings.stdout).ok()?;
131        let name = name.trim().trim_matches('\'');
132        THEMES.get(name).and_then(|themes| {
133            themes.first().and_then(|path| {
134                let file = std::fs::File::open(&path.index).ok()?;
135                let mut reader = std::io::BufReader::new(file);
136
137                let mut line = String::new();
138                while let Ok(read) = reader.read_line(&mut line) {
139                    if read == 0 {
140                        break;
141                    }
142
143                    if let Some(name) = line.strip_prefix("Name=") {
144                        return Some(name.trim().to_owned());
145                    }
146
147                    line.clear();
148                }
149
150                None
151            })
152        })
153    } else {
154        None
155    }
156}
157
158/// The lookup builder struct, holding all the lookup query parameters.
159pub struct LookupBuilder<'a> {
160    name: &'a str,
161    cache: bool,
162    force_svg: bool,
163    scale: u16,
164    size: u16,
165    theme: &'a str,
166}
167
168/// Build an icon lookup for the given icon name.
169///
170/// ## Example
171/// ```rust
172/// # fn main() {
173/// use cosmic_freedesktop_icons::lookup;
174///
175/// let icon = lookup("firefox").find();
176/// # }
177pub fn lookup(name: &str) -> LookupBuilder {
178    LookupBuilder::new(name)
179}
180
181impl<'a> LookupBuilder<'a> {
182    /// Restrict the lookup to the given icon size.
183    ///
184    /// ## Example
185    /// ```rust
186    /// # fn main() {
187    /// use cosmic_freedesktop_icons::lookup;
188    ///
189    /// let icon = lookup("firefox")
190    ///     .with_size(48)
191    ///     .find();
192    /// # }
193    #[inline]
194    pub fn with_size(mut self, size: u16) -> Self {
195        self.size = size;
196        self
197    }
198
199    /// Restrict the lookup to the given scale.
200    ///
201    /// ## Example
202    /// ```rust
203    /// # fn main() {
204    /// use cosmic_freedesktop_icons::lookup;
205    ///
206    /// let icon = lookup("firefox")
207    ///     .with_scale(2)
208    ///     .find();
209    /// # }
210    #[inline]
211    pub fn with_scale(mut self, scale: u16) -> Self {
212        self.scale = scale;
213        self
214    }
215
216    /// Add the given theme to the current lookup :
217    /// ## Example
218    /// ```rust
219    /// # fn main() {
220    /// use cosmic_freedesktop_icons::lookup;
221    ///
222    /// let icon = lookup("firefox")
223    ///     .with_theme("Papirus")
224    ///     .find();
225    /// # }
226    #[inline]
227    pub fn with_theme<'b: 'a>(mut self, theme: &'b str) -> Self {
228        self.theme = theme;
229        self
230    }
231
232    /// Store the result of the lookup in cache, subsequent
233    /// lookup will first try to get the cached icon.
234    /// This can drastically increase lookup performances for application
235    /// that repeat the same lookups, an application launcher for instance.
236    ///
237    /// ## Example
238    /// ```rust
239    /// # fn main() {
240    /// use cosmic_freedesktop_icons::lookup;
241    ///
242    /// let icon = lookup("firefox")
243    ///     .with_scale(2)
244    ///     .with_cache()
245    ///     .find();
246    /// # }
247    #[inline]
248    pub fn with_cache(mut self) -> Self {
249        self.cache = true;
250        self
251    }
252
253    /// By default [`find`] will prioritize Png over Svg icon.
254    /// Use this if you need to prioritize Svg icons. This could be useful
255    /// if you need a modifiable icon, to match a user theme for instance.
256    ///
257    /// ## Example
258    /// ```rust
259    /// # fn main() {
260    /// use cosmic_freedesktop_icons::lookup;
261    ///
262    /// let icon = lookup("firefox")
263    ///     .force_svg()
264    ///     .find();
265    /// # }
266    #[inline]
267    pub fn force_svg(mut self) -> Self {
268        self.force_svg = true;
269        self
270    }
271
272    /// Execute the current lookup
273    /// if no icon is found in the current theme fallback to
274    /// `/usr/share/icons/hicolor` theme and then to `/usr/share/pixmaps`.
275    #[inline]
276    pub fn find(self) -> Option<PathBuf> {
277        if self.name.is_empty() {
278            return None;
279        }
280
281        // Lookup for an icon in the given theme and fallback to 'hicolor' default theme
282        self.lookup_in_theme()
283    }
284
285    fn new<'b: 'a>(name: &'b str) -> Self {
286        Self {
287            name,
288            cache: false,
289            force_svg: false,
290            scale: 1,
291            size: 24,
292            theme: "hicolor",
293        }
294    }
295
296    // Recursively lookup for icon in the given theme and its parents
297    fn lookup_in_theme(&self) -> Option<PathBuf> {
298        // If cache is activated, attempt to get the icon there first
299        // If the icon was previously search but not found, we return
300        // `None` early, otherwise, attempt to perform a lookup
301        if self.cache {
302            match self.cache_lookup(self.theme) {
303                CacheEntry::Found(icon) => return Some(icon),
304                CacheEntry::NotFound(last_check)
305                    if last_check.duration_since(Instant::now()).as_secs() < 5 =>
306                {
307                    return None
308                }
309                _ => (),
310            }
311        }
312
313        // Then lookup in the given theme
314        THEMES
315            .get(self.theme)
316            .or_else(|| THEMES.get("hicolor"))
317            .and_then(|icon_themes| {
318                let icon = icon_themes
319                    .iter()
320                    .find_map(|theme| {
321                        theme.try_get_icon(self.name, self.size, self.scale, self.force_svg)
322                    })
323                    .or_else(|| {
324                        // Fallback to the parent themes recursively
325                        let mut parents = icon_themes
326                            .iter()
327                            .flat_map(|t| {
328                                let Ok(file) = theme::read_ini_theme(&t.index) else {
329                                    return Vec::new();
330                                };
331
332                                let Ok(file) = std::str::from_utf8(file.as_ref()) else {
333                                    return Vec::new();
334                                };
335
336                                t.inherits(file)
337                                    .into_iter()
338                                    .map(String::from)
339                                    .collect::<Vec<String>>()
340                            })
341                            .collect::<Vec<_>>();
342                        parents.dedup();
343                        parents.into_iter().find_map(|parent| {
344                            THEMES.get(&parent).and_then(|parent| {
345                                parent.iter().find_map(|t| {
346                                    t.try_get_icon(self.name, self.size, self.scale, self.force_svg)
347                                })
348                            })
349                        })
350                    })
351                    .or_else(|| {
352                        THEMES.get("hicolor").and_then(|icon_themes| {
353                            icon_themes.iter().find_map(|theme| {
354                                theme.try_get_icon(self.name, self.size, self.scale, self.force_svg)
355                            })
356                        })
357                    })
358                    .or_else(|| {
359                        for theme_base_dir in BASE_PATHS.iter() {
360                            if let Some(icon) =
361                                try_build_icon_path(self.name, theme_base_dir, self.force_svg)
362                            {
363                                return Some(icon);
364                            }
365                        }
366                        None
367                    })
368                    .or_else(|| {
369                        try_build_icon_path(self.name, "/usr/share/pixmaps", self.force_svg)
370                    })
371                    .or_else(|| {
372                        let p = PathBuf::from(&self.name);
373                        if let (Some(name), Some(parent)) = (p.file_stem(), p.parent()) {
374                            try_build_icon_path(&name.to_string_lossy(), parent, self.force_svg)
375                        } else {
376                            None
377                        }
378                    });
379
380                if self.cache {
381                    self.store(self.theme, icon)
382                } else {
383                    icon
384                }
385            })
386    }
387
388    #[inline]
389    pub fn cache_clear(&mut self) {
390        CACHE.clear();
391    }
392
393    #[inline]
394    pub fn cache_reset_none(&mut self) {
395        CACHE.reset_none();
396    }
397
398    #[inline]
399    fn cache_lookup(&self, theme: &str) -> CacheEntry {
400        CACHE.get(theme, self.size, self.scale, self.name)
401    }
402
403    #[inline]
404    fn store(&self, theme: &str, icon: Option<PathBuf>) -> Option<PathBuf> {
405        CACHE.insert(theme, self.size, self.scale, self.name, &icon);
406        icon
407    }
408}
409
410// WARNING: these test are highly dependent on your installed icon-themes.
411// If you want to run them, make sure you have 'Papirus' and 'Arc' icon-themes installed.
412#[cfg(test)]
413#[cfg(feature = "local_tests")]
414mod test {
415    use crate::{lookup, CacheEntry, CACHE};
416    use speculoos::prelude::*;
417    use std::path::PathBuf;
418
419    #[test]
420    fn simple_lookup() {
421        let firefox = lookup("firefox").find();
422
423        asserting!("Lookup with no parameters should return an existing icon")
424            .that(&firefox)
425            .is_some()
426            .is_equal_to(PathBuf::from(
427                "/usr/share/icons/hicolor/22x22/apps/firefox.png",
428            ));
429    }
430
431    #[test]
432    fn theme_lookup() {
433        let firefox = lookup("firefox").with_theme("Papirus").find();
434
435        asserting!("Lookup with no parameters should return an existing icon")
436            .that(&firefox)
437            .is_some()
438            .is_equal_to(PathBuf::from(
439                "/usr/share/icons/Papirus/24x24/apps/firefox.svg",
440            ));
441    }
442
443    #[test]
444    fn should_fallback_to_parent_theme() {
445        let icon = lookup("video-single-display-symbolic")
446            .with_theme("Arc")
447            .find();
448
449        asserting!("Lookup for an icon in the Arc theme should find the icon in its parent")
450            .that(&icon)
451            .is_some()
452            .is_equal_to(PathBuf::from(
453                "/usr/share/icons/Adwaita/symbolic/devices/video-single-display-symbolic.svg",
454            ));
455    }
456
457    #[test]
458    fn should_fallback_to_pixmaps_utlimately() {
459        let archlinux_logo = lookup("archlinux-logo")
460            .with_size(16)
461            .with_scale(1)
462            .with_theme("Papirus")
463            .find();
464
465        asserting!("When lookup fail in theme, icon should be found in '/usr/share/pixmaps'")
466            .that(&archlinux_logo)
467            .is_some()
468            .is_equal_to(PathBuf::from("/usr/share/pixmaps/archlinux-logo.png"));
469    }
470
471    #[test]
472    fn compare_to_linincon_with_theme() {
473        let lin_wireshark = linicon::lookup_icon("wireshark")
474            .next()
475            .unwrap()
476            .unwrap()
477            .path;
478
479        let wireshark = lookup("wireshark")
480            .with_size(16)
481            .with_scale(1)
482            .with_theme("Papirus")
483            .find();
484
485        asserting!("Given the same input parameter, lookup should output be the same as linincon")
486            .that(&wireshark)
487            .is_some()
488            .is_equal_to(lin_wireshark);
489    }
490
491    #[test]
492    fn should_not_attempt_to_lookup_a_not_found_cached_icon() {
493        let not_found = lookup("not-found").with_cache().find();
494
495        assert_that!(not_found).is_none();
496
497        let expected_cache_result = CACHE.get("hicolor", 24, 1, "not-found");
498
499        asserting!("When lookup fails a first time, subsequent attempts should fail from cache")
500            .that(&expected_cache_result)
501            .is_equal_to(CacheEntry::NotFound);
502    }
503}