icon 0.2.0

Reality-compliant library to find icons on linux with ease
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
use crate::ThemeParseError::MissingRequiredAttribute;
use crate::icon::IconFile;
use freedesktop_entry_parser::low_level::{SectionBytes, SectionBytesIter};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;

pub(crate) type DirectoryRef = usize;

/// An icon theme.
pub struct Theme {
    /// Properties of this theme and all of its subdirectories.
    pub info: ThemeInfo,
    /// References to the `Theme`s that this theme depends on.
    ///
    /// When querying for an icon that doesn't exist in this theme, the themes in its `inherits_from`
    /// list will be checked for that icon instead.
    pub inherits_from: Vec<Arc<Theme>>,
}

impl Theme {
    /// Find an icon in this theme or any of its dependencies, with scale equal to 1.
    ///
    /// Also see [find_icon](Theme::find_icon)
    pub fn find_icon_unscaled(&self, icon_name: &str, size: u32) -> Option<IconFile> {
        self.find_icon(icon_name, size, 1)
    }

    /// Find an icon in this theme or any of its dependencies.
    ///
    /// This function seeks to replicate the behaviour from the official icon lookup mechanism from
    /// the Icon Theme specification, which you can find [here](https://specifications.freedesktop.org/icon-theme/latest/#icon_lookup).
    ///
    /// Arguments:
    /// - `icon_name`: the canonical name of the icon **without** file extension.
    /// - `size`: the size, in pixels, desired. The returned icon may not be this exact size in case an exact match couldn't be found.
    /// - `scale`: the scale at which the icon will be displayed.
    pub fn find_icon(&self, icon_name: &str, size: u32, scale: u32) -> Option<IconFile> {
        self.find_icon_here(icon_name, size, scale).or_else(|| {
            // or find it in one of our parents
            self.inherits_from
                .iter()
                .find_map(|theme| theme.find_icon_here(icon_name, size, scale))
        })
    }

    /// Find an icon in this theme only.
    ///
    /// Do not use this function if you need normal icon finding behaviour: use [find_icon](Theme::find_icon) instead.
    pub fn find_icon_here(&self, icon_name: &str, size: u32, scale: u32) -> Option<IconFile> {
        // first, try to find an exact icon size match:
        let exact_sub_dirs = self.exact_sub_dirs_for(size, scale);
        if let Some(exact_match_icon) = exact_sub_dirs
            .flat_map(|exact_sub_dir| self.find_icon_in_directory(icon_name, exact_sub_dir))
            .next()
        {
            // and return it if found!
            return Some(exact_match_icon);
        }

        // no exact match: try to find a match as close as possible instead.

        // in order to reduce file exist syscalls,
        // we opt to do the hopefully _less expensive_ operation of sorting the subdirectories instead,
        // from the smallest size_distance to largest.
        // that gives us the assurance that the first icon found, is the best one.
        let mut sub_dirs = self.info.index.directories.iter().collect::<Vec<_>>();
        sub_dirs.sort_by_key(|sub_dir| sub_dir.size_distance(size, scale));

        for sub_dir in sub_dirs {
            for base_dir in &self.info.base_dirs {
                for file_name in &Self::possible_file_names_for(icon_name) {
                    let path = base_dir
                        .join(sub_dir.directory_name.as_str())
                        .join(file_name);
                    if path.exists()
                        && let Some(file) = IconFile::from_path(&path)
                    {
                        return Some(file);
                    }
                }
            }
        }

        None
    }

    #[allow(unused)] // Used with certain crate features.
    pub(crate) fn find_icon_files(
        &self,
        icon_name: &str,
    ) -> impl Iterator<Item = (DirectoryRef, IconFile)> {
        self.info
            .index
            .directories
            .iter()
            .enumerate()
            .flat_map(|(index, di)| {
                self.find_icon_in_directory(icon_name, di)
                    .map(|icon| (index, icon))
            })
    }

    fn exact_sub_dirs_for(
        &self,
        size: u32,
        scale: u32,
    ) -> impl Iterator<Item = &DirectoryIndex> + Clone {
        self.info
            .index
            .directories
            .iter()
            .filter(move |sub_dir| sub_dir.matches_size(size, scale))
    }

    fn possible_file_names_for(icon_name: &str) -> [String; 3] {
        const EXTENSIONS: [&str; 3] = ["png", "xpm", "svg"];

        EXTENSIONS.map(|ext| format!("{icon_name}.{ext}"))
    }

    pub(crate) fn find_icon_in_directory(
        &self,
        icon_name: &str,
        directory: &DirectoryIndex,
    ) -> Option<IconFile> {
        let file_names = Self::possible_file_names_for(icon_name);

        for base_dir in &self.info.base_dirs {
            for file_name in &file_names {
                let path = base_dir
                    .join(directory.directory_name.as_str())
                    .join(file_name);

                let path_exists = path.exists();

                if path_exists && let Some(file) = IconFile::from_path(&path) {
                    // exact match!
                    return Some(file);
                }
            }
        }

        None
    }
}

/// Information about an icon theme.
///
/// Its formal description (called the index) can be found in the `index` field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeInfo {
    /// The name of the directory wherein this theme lives.
    ///
    /// This is different from the theme's actual name, which is specified in its index. (See `index.name`)
    pub internal_name: OsString,
    /// The directories in which this theme's icons live.
    ///
    /// The Icon Theme specification allows a theme to be split up over multiple directories
    /// (of the same internal name) in each of the base directories applications look for themes.
    /// This list holds the paths to all directories where this theme is specified.
    pub base_dirs: Vec<PathBuf>,
    /// Although icon themes may be split up over multiple directories, each icon theme is only
    /// allowed one `index.theme` file to dictate the theme's properties. Applications must use the
    /// first `index.theme` file they find when searching base directories; this field holds the
    /// path to that file.
    pub index_location: PathBuf,
    /// The contents of the `index.theme` file.
    pub index: ThemeIndex,
    // additional groups?
}

/// An error occurred during theme index parsing.
///
/// This type is returned by [ThemeIndex::parse] and indirectly by [ThemeInfo::new_from_folders].
#[derive(Debug, thiserror::Error)]
pub enum ThemeParseError {
    /// Missing the "Icon Theme" section.
    #[error("missing Icon Theme index or section")]
    NotAnIconTheme,
    /// An attribute that is required, is missing.
    #[error("missing attribute `{0}`")]
    MissingRequiredAttribute(&'static str),
    /// The file isn't encoded in UTF-8.
    #[error("the input wasn't in utf-8")]
    NotUtf8(#[from] std::str::Utf8Error),
    /// Couldn't parse a `bool`ean where one was expected.
    #[error("a bool was expected but failed to parse")]
    ParseBoolError(#[from] std::str::ParseBoolError),
    /// Couldn't parse a `bool`ean where one was expected.
    #[error("a number was expected but failed to parse")]
    ParseNumError(#[from] std::num::ParseIntError),
    /// Couldn't parse a [DirectoryType](DirectoryType) where one was expected.
    #[error("A directory type was invalid")]
    InvalidDirectoryType,
    /// The file was not properly formatted as a freedesktop entry file.
    ///
    /// Entry files look like `.ini` files, but they are not the same.
    /// Check out the specification for entry files [here](https://specifications.freedesktop.org/desktop-entry/latest/basic-format.html).
    #[error("invalid format for a freedesktop entry file")]
    ParseError(#[from] freedesktop_entry_parser::low_level::ParseError),
}

impl ThemeInfo {
    /// Create a new `ThemeInfo` from a theme's internal name and the folders at which the theme
    /// lives.
    ///
    /// This function will parse the first `index.theme` file found in the directories passed in.
    pub fn new_from_folders(
        internal_name: OsString,
        folders: Vec<PathBuf>,
    ) -> std::io::Result<Self> {
        let index_location = folders
            .iter()
            .map(|f| f.join("index.theme"))
            .find(|index_path| index_path.exists())
            .ok_or_else(|| std::io::Error::other(ThemeParseError::NotAnIconTheme))?;

        let index = ThemeIndex::parse_from_file(index_location.as_path())?;

        Ok(Self {
            internal_name,
            base_dirs: folders,
            index_location,
            index,
        })
    }
}

/// The "formal description" of a theme as specified by the Icon Theme specification.
///
/// Every icon theme must 'describe' itself using an index file. It contains useful information such
/// as a human-readable name for the theme, which themes it depends on (i.e. fallbacks),
/// and a complete listing of all directories associated with the icon theme along with their
/// properties.
///
/// All doc comments in *italics* below are copy-pasted from the XDG Icon Theme Specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeIndex {
    /// *Short name of the icon theme, used in e.g. lists when selecting themes.*
    pub name: String,
    /// *Longer string describing the theme*
    pub comment: String,
    /// *The name of the theme that this theme inherits from. If an icon name is not found in the current theme, it is searched for in the inherited theme (and recursively in all the inherited themes).*
    ///
    /// *If no theme is specified, implementations are required to add the "hicolor" theme to the inheritance tree. An implementation may optionally add other default themes in between the last specified theme and the hicolor theme.*
    ///
    /// *Themes that are inherited from explicitly must be present on the system.*
    pub inherits: Vec<String>,
    /// Directories associated with this icon theme. This compounds the "Directories" **and**
    /// "ScaledDirectories" entries of the index.
    ///
    /// "Directories": *List of subdirectories for this theme. For every subdirectory there must be a section in the `index.theme` file describing that directory.* \
    /// "ScaledDirectories": *Additional list of subdirectories for this theme, in addition to the ones in Directories. These directories should only be read by implementations supporting scaled directories and was added to keep compatibility with old implementations that don't support these.*
    pub directories: Vec<DirectoryIndex>,
    /// *Whether to hide the theme in a theme selection user interface. This is used for things such as fallback-themes that are not supposed to be visible to the user.*
    pub hidden: bool,
    /// *The name of an icon that should be used as an example of how this theme looks.*
    pub example: Option<String>,
}

impl ThemeIndex {
    /// Parse an icon theme index file by path.
    ///
    /// If this function fails to read the file, it will return the IO error that caused failure. \
    /// If parsing the contents of the file failed, it will return [std::io::Error::other] with the
    ///   responsible [ThemeParseError] inside.
    pub fn parse_from_file(path: &Path) -> std::io::Result<Self> {
        let bytes = std::fs::read(path)?;
        let index = ThemeIndex::parse(&bytes).map_err(std::io::Error::other)?;

        Ok(index)
    }

    /// Parse an icon theme index directory from the content, in bytes, of the file.
    ///
    /// See [ThemeParseError] for the errors this function may return.
    pub fn parse(bytes: &[u8]) -> Result<Self, ThemeParseError> {
        let mut entry: SectionBytesIter = freedesktop_entry_parser::low_level::parse_entry(bytes);

        let icon_theme_section: SectionBytes =
            entry.next().ok_or(ThemeParseError::NotAnIconTheme)??;
        let name: &str = find_attr_req(&icon_theme_section, "Name")?;

        // SPEC: `Comment` is required, but most icon theme developers can't be arsed to
        // include it! To make `icon` practical, we choose a default of an empty string instead.
        // `let comment = find_attr_req(&icon_theme_section, "Comment")?;`
        let comment = find_attr(&icon_theme_section, "Comment")?.unwrap_or("");
        // If no theme is specified, implementations are required to add the "hicolor" theme to the inheritance tree.
        let inherits = find_attr(&icon_theme_section, "Inherits")?
            .iter()
            .flat_map(|s| s.split(',')) // `inherits` is a comma-separated string list
            .map(Into::into)
            .collect::<Vec<_>>();
        let directories = find_attr_req(&icon_theme_section, "Directories")?
            .split(',')
            .collect::<Vec<_>>();
        let scaled_directories = find_attr(&icon_theme_section, "ScaledDirectories")?
            .map(|s| s.split(',').collect::<Vec<_>>());
        let hidden = find_attr(&icon_theme_section, "Hidden")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(false);
        let example = find_attr(&icon_theme_section, "Example")?;

        // all other sections should describe a directory in the directory list
        let directories = entry
            .filter_map(Result::ok)
            .filter_map(|section| {
                let title = str::from_utf8(section.title).ok()?;

                let is_scaled_dir = scaled_directories
                    .as_ref()
                    .map(|d| d.contains(&title))
                    .unwrap_or(false);

                if !directories.contains(&title) && !is_scaled_dir {
                    // this section isn't a listed directory! ignore!
                    return None;
                }

                let mut index = DirectoryIndex::parse(section);

                if is_scaled_dir && let Ok(index) = &mut index {
                    index.is_scaled_dir = true;
                }

                Some(index)
            })
            .collect::<Result<Vec<_>, ThemeParseError>>()?;

        Ok(Self {
            name: name.into(),
            comment: comment.into(),
            inherits,
            directories,
            hidden,
            example: example.map(Into::into),
        })
    }
}

/// The "formal description" of a subdirectory in an Icon Theme, as specified by the Icon Theme
/// specification.
///
/// All doc comments in *italics* below are copy-pasted from the XDG Icon Theme Specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectoryIndex {
    /// The name of the subdirectory as found in the theme's index file.
    ///
    /// It is not guaranteed that a subdirectory with the same name actually exists.
    pub directory_name: String,
    /// Is this directory listed as a "normal" subdirectory (holding specific sizes of icons) or a "scaled" directory (holding scalable graphics)?
    pub is_scaled_dir: bool,
    /// *Nominal (unscaled) size of the icons in this directory.*
    ///
    /// This is the only required field; all others assume their default value if not present.
    pub size: u32,
    /// *Target scale of the icons in this directory. Defaults to the value 1 if not present. Any directory with a scale other than 1 should be listed in the ScaledDirectories list rather than Directories for backwards compatibility.*
    pub scale: u32,
    /// *The context the icon is normally used in. This is in detail discussed in [Section 4.1, “Context”](https://specifications.freedesktop.org/icon-theme/latest/#context).*
    pub context: Option<String>,
    /// *The type of icon sizes for the icons in this directory. Valid types are `Fixed`, `Scalable` and `Threshold`. The type decides what other keys in the section are used. If not specified, the default is `Threshold`.*
    pub directory_type: DirectoryType,
    /// *Specifies the maximum (unscaled) size that the icons in this directory can be scaled to. Defaults to the value of `size` if not present.*
    pub max_size: u32,
    /// *Specifies the minimum (unscaled) size that the icons in this directory can be scaled to. Defaults to the value of `size` if not present.*
    pub min_size: u32,
    /// *The icons in this directory can be used if the size differ at most this much from the desired (unscaled) size. Defaults to *2* if not present.*
    pub threshold: u32,
    // pub additional_values: HashMap<String, String>,
}

impl DirectoryIndex {
    fn parse(section: SectionBytes) -> Result<Self, ThemeParseError> {
        let dir_name = str::from_utf8(section.title)?;
        let size: u32 = find_attr_req(&section, "Size")?.parse()?;
        let scale: u32 = find_attr(&section, "Scale")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(1);
        let context = find_attr(&section, "Context")?;
        // Valid types are Fixed, Scalable and Threshold.
        // The type decides what other keys in the section are used.
        // If not specified, the default is Threshold.
        let directory_type = find_attr(&section, "Type")?
            .map(|s| s.try_into())
            .transpose()
            .map_err(|_| ThemeParseError::InvalidDirectoryType)?
            .unwrap_or(DirectoryType::Threshold);
        let max_size = find_attr(&section, "MaxSize")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(size);
        let min_size = find_attr(&section, "MinSize")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(size);
        let threshold = find_attr(&section, "Threshold")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(2);

        Ok(Self {
            directory_name: dir_name.into(),
            is_scaled_dir: scale != 1,
            size,
            scale,
            context: context.map(Into::into),
            directory_type,
            max_size,
            min_size,
            threshold,
        })
    }

    pub(crate) fn size_distance(&self, icon_size: u32, icon_scale: u32) -> u32 {
        let size = icon_size * icon_scale;

        match self.directory_type {
            DirectoryType::Fixed => (self.size * self.scale).abs_diff(size),
            DirectoryType::Scalable => {
                let lower = self.min_size * self.scale;
                let higher = self.max_size * self.scale;

                if size < lower {
                    lower - size
                } else {
                    size.saturating_sub(higher)
                }
            }
            DirectoryType::Threshold => {
                let lower = (self.size - self.threshold) * self.scale;
                let higher = (self.size + self.threshold) * self.scale;

                if size < lower {
                    size.abs_diff(self.min_size * self.scale)
                } else if size > higher {
                    size.abs_diff(self.max_size * self.scale)
                } else {
                    0 // within range -> no distance!
                }
            }
        }
    }

    /// Computes whether this directory "supports" icons with the provided `icon_size` (in pixels)
    /// and scale (as a multiple of the size).
    ///
    /// The behaviour of this method depends on the [DirectoryType](DirectoryIndex#structfield.directory_type) of this directory.
    /// If the type is:
    /// - [DirectoryType::Fixed]: Only icons with the same size and scale as the directory match.
    /// - [DirectoryType::Scalable]: Icons with a size between the directory's `min_size` and `max_size`, and equal scale, match.
    /// - [DirectoryType::Threshold]: `icon_size` may only differ by the amount of `threshold` specified by the directory, and scale must match exactly.
    ///
    /// When this method returns `true`, the "size distance" of the provided size and scale to the directory's size and scale is considered to be 0.
    pub fn matches_size(&self, icon_size: u32, icon_scale: u32) -> bool {
        if self.scale != icon_scale {
            return false;
        }

        match self.directory_type {
            DirectoryType::Fixed => self.size == icon_size,
            DirectoryType::Scalable => {
                let DirectoryIndex {
                    min_size, max_size, ..
                } = *self;

                (min_size..=max_size).contains(&icon_size)
            }
            DirectoryType::Threshold => {
                let DirectoryIndex {
                    threshold, size, ..
                } = *self;

                // The icons in this directory can be used if the size differ at most this much from the desired (unscaled) size
                size.abs_diff(icon_size) <= threshold
            }
        }
    }
}

/// The type of image scaling used for an icon theme subdirectory.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum DirectoryType {
    /// Fixed-size images ([FileType::Png](crate::FileType::Png) and [FileType::Xpm](crate::FileType::Xpm)); these icons may not be scaled to any other size.
    Fixed,
    /// For scalable (vector) graphics ([FileType::Svg](crate::FileType::Svg))
    Scalable,
    /// For fixed-size images that may be scaled within a specified threshold.
    ///
    /// This is the default type, and by default the threshold is 2 pixels.
    Threshold,
}

/// The `Default` implementation for `DirectoryType` returns [DirectoryType::Threshold].
///
/// This is because the XDG Icon Theme specification mandates that if the type for a directory is
/// not specified, it is chosen to be `Threshold`.
impl Default for DirectoryType {
    fn default() -> Self {
        DirectoryType::Threshold
    }
}

impl TryFrom<&str> for DirectoryType {
    type Error = ();

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let value = match value {
            "Fixed" => DirectoryType::Fixed,
            "Scalable" => DirectoryType::Scalable,
            "Threshold" => DirectoryType::Threshold,
            _ => return Err(()),
        };

        Ok(value)
    }
}

fn find_attr<'a>(
    section: &'a SectionBytes,
    name: &str,
) -> Result<Option<&'a str>, std::str::Utf8Error> {
    section
        .attrs
        .iter()
        .find(|attr| attr.name == name.as_bytes() && attr.param.is_none())
        .map(|attr| str::from_utf8(&attr.value))
        .transpose()
}

fn find_attr_req<'a>(
    section: &'a SectionBytes,
    name: &'static str,
) -> Result<&'a str, ThemeParseError> {
    find_attr(section, name)?.ok_or(MissingRequiredAttribute(name))
}

#[cfg(test)]
mod test {
    use crate::Icons;
    use crate::icon::FileType;
    use crate::search::test::test_search;
    use crate::{DirectoryType, ThemeIndex};
    use std::error::Error;
    use std::path::Path;
    use std::time::{Duration, Instant};

    #[test]
    fn test_find_test_icon() {
        let icons = test_search().search().icons();

        let big_ico = icons.find_icon("happy", 64, 1, "TestTheme").unwrap();
        assert!(big_ico.path().ends_with("TestTheme/32x32/foo/happy.png"));
        assert_eq!(big_ico.file_type(), FileType::Png);

        let small_ico = icons.find_icon("happy", 16, 1, "TestTheme").unwrap();
        assert!(
            small_ico.path().ends_with("TestTheme/16x16/α/happy.png"),
            "{:?} matches the expected value",
            small_ico.path()
        );
        assert_eq!(small_ico.file_type(), FileType::Png);
    }

    #[test]
    fn find_all_desktop_entry_icons() {
        let icons = Icons::new();

        // some desktop files are just packaged poorly.
        // if a test fails here, and you are certain that the icon just straight up doesn't exist,
        // or is in an unfindable place by normal means,
        // disallow it in this list.
        static DISALLOW_LIST: &[&str] = &[
            "imv-dir",
            "imv",
            "io.elementary.granite.demo",
            "java-java-openjdk",
            "jconsole-java-openjdk",
            "jshell-java-openjdk",
            "lstopo",
            "signon-ui",
        ];

        let mut time_taken = Duration::ZERO;
        let mut n = 0;

        for entry in
            freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths())
                .entries(None::<&[&str]>)
        {
            let Some(icon_name) = entry.icon() else {
                continue;
            };

            if Path::new(icon_name).exists() {
                continue; // absolute URLs to icons are OK
            }

            if DISALLOW_LIST
                .iter()
                .any(|x| Some(x.as_ref()) == entry.path.file_stem())
            {
                continue;
            }

            let then = Instant::now();

            // TODO: perhaps our system should expose a way to construct a "composed theme" filter,
            // for cases where you want to search a multitude (or all) themes
            let icon = icons
                .find_icon(icon_name, 32, 1, "gnome")
                .or_else(|| icons.find_icon(icon_name, 32, 1, "breeze"));

            time_taken += Instant::now() - then;
            n += 1;

            assert!(
                icon.is_some(),
                "Icon {icon_name} from desktop entry {:?} missing!!",
                entry.path
            )
        }

        println!("avg {:?} per icon", time_taken / n);
    }

    #[test]
    fn test_parse_example_theme() -> Result<(), Box<dyn Error>> {
        static EXAMPLE: &'static str = include_str!("../resources/example.index.theme");

        let index = ThemeIndex::parse(EXAMPLE.as_bytes())?;

        assert_eq!(index.name, "Birch");
        assert_eq!(index.comment, "Icon theme with a wooden look");
        assert_eq!(index.inherits, vec!["wood", "default"]);

        let directories = index.directories;

        assert_eq!(directories.len(), 7);

        let first_dir_index = &directories[0];
        assert_eq!(first_dir_index.directory_name, "scalable/apps");
        assert_eq!(first_dir_index.is_scaled_dir, false);
        assert_eq!(first_dir_index.size, 48);
        assert_eq!(first_dir_index.scale, 1);
        assert_eq!(first_dir_index.context.as_deref(), Some("Applications"));
        assert_eq!(first_dir_index.directory_type, DirectoryType::Scalable);
        assert_eq!(first_dir_index.max_size, 256);
        assert_eq!(first_dir_index.min_size, 1);
        assert_eq!(first_dir_index.threshold, 2);

        assert_eq!(index.hidden, false);
        assert_eq!(index.example, None);

        Ok(())
    }
}