freedesktop_icon_lookup/
lookup.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use either::Either;
5
6use crate::{Error, IconInfo, IconSearch, Result, Theme};
7
8const DEFAULT_THEME: &str = "hicolor";
9
10/// Icon cache, before lookups one may need to load required theme(s)
11/// explicitly either with [Cache::load] or [Cache::load_default].
12pub struct Cache {
13    themes: HashMap<String, Vec<Theme>>,
14    pixmaps: HashMap<String, PathBuf>,
15}
16
17/// Search parameters for [Cache::lookup_param] search.
18pub struct LookupParam<'a> {
19    name: &'a str,
20    theme: Option<&'a str>,
21    size: Option<u16>,
22    scale: Option<u16>,
23}
24
25impl Cache {
26    /// Creates new cache. Most of the lookups are to be failed at this point.
27    /// Consider loading icons afterwards.
28    pub fn new() -> Result<Self> {
29        Ok(Self {
30            themes: HashMap::new(),
31            pixmaps: {
32                let mut pixmaps = HashMap::new();
33                crate::find_dir_icons("/usr/share/pixmaps", |icon_name, path| {
34                    pixmaps.insert(icon_name.into(), path);
35                })?;
36                pixmaps
37            },
38        })
39    }
40
41    /// Returns iterator with a loaded themes.
42    pub fn themes(&self) -> impl Iterator<Item = &str> + '_ {
43        self.themes.keys().map(|s| s.as_str())
44    }
45
46    /// Load icons for default (HiColor) icon theme.
47    pub fn load_default(&mut self) -> Result<()> {
48        self.load(DEFAULT_THEME)
49    }
50
51    /// Load icons for specified icon theme.
52    pub fn load(&mut self, theme: impl Into<String>) -> Result<()> {
53        let themes_count = self.themes.len();
54        self.load_inner(theme, 0).and_then(|()| {
55            if themes_count == self.themes.len() {
56                Err(Error::ThemeNotFound)
57            } else {
58                Ok(())
59            }
60        })
61    }
62
63    fn load_inner(&mut self, theme: impl Into<String>, depth: usize) -> Result<()> {
64        let theme = theme.into();
65        if self.themes.contains_key(&theme) {
66            return Ok(());
67        }
68
69        // In case of cyclic inherits
70        if depth > 10 {
71            return Err(Error::CycleDetected);
72        }
73
74        for path in search_dirs() {
75            let path = path.join(&theme);
76            if path.exists() {
77                let t = match Theme::new(&path) {
78                    Ok(t) => t,
79                    Err(e) => {
80                        #[cfg(feature = "log")]
81                        log::error!("theme loading failed: {e} at{}", path.display());
82                        #[cfg(not(feature = "log"))]
83                        let _ = e;
84                        continue;
85                    }
86                };
87
88                for inherit in t.inherits() {
89                    self.load_inner(inherit, depth + 1)?;
90                }
91
92                if t.inherits().iter().all(|x| self.themes.contains_key(x)) {
93                    self.themes.entry(theme.clone()).or_default().push(t);
94                } else {
95                    #[cfg(feature = "log")]
96                    log::warn!(
97                        "skipping {theme} as inherited {} was not loaded",
98                        t.inherits().join(",")
99                    );
100                }
101            }
102        }
103
104        Ok(())
105    }
106
107    /// Advanced icon lookup provides general solution over [Cache::lookup].
108    /// Similarly it requires an icon `name` and optional `theme` to look for.
109    /// Also a provided closure is called for a list of discovered [IconInfo]'s.
110    /// Notice that icons list might be incomplete (e.g doesn't include inherited themes).
111    pub fn lookup_advanced<'a, F>(
112        &'a self,
113        name: &str,
114        theme: impl Into<Option<&'a str>>,
115        f: F,
116    ) -> Option<PathBuf>
117    where
118        F: FnMut(&[IconInfo]) -> Option<usize> + Copy,
119    {
120        self.lookup_themed(theme.into().unwrap_or(DEFAULT_THEME), name, f, 0)
121            .map(|s| s.path())
122            .or_else(|| self.pixmaps.get(name).cloned())
123    }
124
125    /// Icon lookup for a specified `name` and optional `theme` to look for.
126    /// If theme is unspecified the default one is used.
127    pub fn lookup<'a>(&'a self, name: &str, theme: impl Into<Option<&'a str>>) -> Option<PathBuf> {
128        self.lookup_param(LookupParam::new(name).with_theme(theme.into()))
129    }
130
131    /// Icon lookup with a provided [LookupParam]. It works as described in
132    /// [Freedesktop icon lookup spec](https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html).
133    pub fn lookup_param<'a>(&'a self, param: LookupParam<'a>) -> Option<PathBuf> {
134        self.lookup_themed(
135            param.theme.unwrap_or(DEFAULT_THEME),
136            param.name,
137            |infos| {
138                let (icon_size, icon_scale) = (param.size(), param.scale());
139
140                if let Some(idx) = infos
141                    .iter()
142                    .position(|i| i.directory().is_matches(icon_size, icon_scale))
143                {
144                    return Some(idx);
145                }
146
147                if let Some((idx, _)) = infos
148                    .iter()
149                    .enumerate()
150                    .min_by_key(|(_, i)| i.directory().size_distance(icon_size, icon_scale))
151                {
152                    return Some(idx);
153                }
154
155                None
156            },
157            0,
158        )
159        .map(|s| s.path())
160        .or_else(|| self.pixmaps.get(param.name).cloned())
161    }
162
163    fn lookup_themed<'a, F>(
164        &'a self,
165        theme: &str,
166        icon_name: &'a str,
167        f: F,
168        depth: usize,
169    ) -> Option<IconSearch<'a>>
170    where
171        F: FnMut(&[IconInfo]) -> Option<usize> + Copy,
172    {
173        // In case of cyclic inherits
174        if depth > 10 {
175            return None;
176        }
177
178        let themes = self.themes.get(theme)?;
179        for theme in themes {
180            if let Some(search) = theme.icon_search(icon_name, f) {
181                return Some(search);
182            }
183        }
184
185        for theme in themes.iter().flat_map(|t| t.inherits()) {
186            if let Some(search) = self.lookup_themed(theme, icon_name, f, depth + 1) {
187                return Some(search);
188            }
189        }
190
191        None
192    }
193}
194
195impl<'a> LookupParam<'a> {
196    pub fn new(name: &'a str) -> Self {
197        Self {
198            name,
199            theme: None,
200            size: None,
201            scale: None,
202        }
203    }
204
205    pub fn with_theme(mut self, theme: Option<&'a str>) -> Self {
206        self.theme = theme;
207        self
208    }
209
210    pub fn with_size(mut self, size: u16) -> Self {
211        self.size = Some(size);
212        self
213    }
214
215    pub fn with_scale(mut self, scale: u16) -> Self {
216        self.scale = Some(scale);
217        self
218    }
219
220    fn size(&self) -> u16 {
221        self.size.unwrap_or(48)
222    }
223
224    fn scale(&self) -> u16 {
225        self.size.unwrap_or(1)
226    }
227}
228
229fn search_dirs() -> impl Iterator<Item = PathBuf> {
230    use std::iter::once;
231
232    let home_dir = std::env::var("HOME");
233
234    home_dir
235        .as_ref()
236        .map(|var| PathBuf::from(var).join(".icons"))
237        .into_iter()
238        // this one seems to be missing in the spec, though seems intuitive
239        .chain(
240            std::env::var("XDG_DATA_HOME")
241                .map(|var| PathBuf::from(var).join("icons"))
242                .or_else(|_| home_dir.map(|var| PathBuf::from(var).join(".local/share/icons")))
243                .into_iter(),
244        )
245        .chain(if let Ok(dirs) = std::env::var("XDG_DATA_DIRS") {
246            Either::Left(
247                dirs.split(':')
248                    .map(|s| Path::new(s).join("icons"))
249                    .collect::<Vec<_>>()
250                    .into_iter(),
251            )
252        } else {
253            Either::Right(
254                once(PathBuf::from("/usr/share/local/icons"))
255                    .chain(once(PathBuf::from("/usr/share/icons"))),
256            )
257        })
258}
259
260pub(crate) fn find_dir_icons<P, F>(path: P, mut f: F) -> Result<()>
261where
262    P: AsRef<Path>,
263    F: FnMut(&str, PathBuf),
264{
265    let path = path.as_ref();
266
267    fn filter_io_errors<T>(r: std::io::Result<T>) -> Result<Option<T>> {
268        use std::io::ErrorKind;
269        match r {
270            Ok(v) => Ok(Some(v)),
271            Err(e) if matches!(e.kind(), ErrorKind::PermissionDenied | ErrorKind::NotFound) => {
272                Ok(None)
273            }
274            Err(source) => Err(Error::TraverseDir { source }),
275        }
276    }
277
278    for e in filter_io_errors(std::fs::read_dir(path))?
279        .into_iter()
280        .flatten()
281    {
282        if let Some(entry) = filter_io_errors(e)? {
283            if filter_io_errors(entry.file_type())?.map_or(true, |f| f.is_dir()) {
284                continue;
285            }
286
287            let path = entry.path();
288            if let Some(icon_name) = path.file_name().and_then(|s| s.to_str()) {
289                let icon_name = &icon_name[0..icon_name.rfind('.').unwrap_or(0)];
290                f(icon_name, entry.path());
291            }
292        }
293    }
294    Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn default_search_dirs() {
303        std::env::remove_var("XDG_DATA_HOME");
304        std::env::remove_var("XDG_DATA_DIRS");
305        std::env::set_var("HOME", "/tmp");
306        // `/usr/share/pixmaps` handled separately as it doesn't have themes.
307        assert_eq!(
308            vec![
309                "/tmp/.icons",
310                "/tmp/.local/share/icons",
311                "/usr/share/local/icons",
312                "/usr/share/icons"
313            ],
314            search_dirs()
315                .map(|p| p.to_str().unwrap().to_string())
316                .collect::<Vec<_>>()
317        );
318    }
319}