icon_cache/
lib.rs

1//! Complete and user-friendly zero-copy wrappers for the GTK icon cache which is present on most
2//! linux systems.
3//!
4//! GTK's icon cache maintains a hash-indexed map from icon names (e.g. `open-menu`) to a list of
5//! images representing that icon, each in a different directory, usually denoting that icon's size,
6//! whether it's scalable, etc.
7//!
8//! This crate provides a safe wrapper around this cache and is designed for use with `mmap`.
9//! To get started, look at [IconCache].
10//!
11//! # Feature flags
12//!
13//! * `file`: Enables the [mod@file] module to safely open and memory-map cache files. Enabled by default.
14
15use std::error::Error;
16use std::ffi::CStr;
17use std::path::Path;
18use zerocopy::FromBytes;
19
20#[cfg(feature = "file")]
21pub mod file;
22pub mod raw;
23
24/// Thin wrapper around an in-memory icon cache.
25///
26/// This is `icon-cache`'s main entrypoint. To look up an icon, use the [icon](IconCache::icon) function.
27///
28/// `IconCache`'s fields may be interesting for advanced uses, but if all you need is to look up
29/// icons—use [icon](IconCache::icon).
30#[derive(derive_more::Debug, Copy, Clone)]
31pub struct IconCache<'a> {
32    /// The raw bytes representing the cache
33    #[debug(skip)]
34    pub bytes: &'a [u8],
35    /// Cache header file: contains version and hash & directory list offsets
36    pub header: &'a raw::Header,
37    /// Internal hash table storing mapping from icon names to icon information
38    pub hash: &'a raw::Hash,
39    /// List of directories within the theme, relative to the theme's root
40    pub directory_list: DirectoryList<'a>,
41}
42
43impl<'a> IconCache<'a> {
44    pub fn new_from_bytes(bytes: &'a [u8]) -> Result<Self, Box<dyn Error + 'a>> {
45        let (header, _) = raw::Header::ref_from_prefix(bytes)?;
46
47        let hash = header.hash.at(bytes)?;
48        let directory_list = header.directory_list.at(bytes)?;
49        let directory_list = DirectoryList {
50            bytes,
51            raw_list: directory_list,
52        };
53
54        Ok(IconCache {
55            bytes,
56            header,
57            hash,
58            directory_list,
59        })
60    }
61
62    /// Look up an icon by name in the cache. `icon_name` accepts any type that turns into a byte
63    /// slice: typically `str` suffices.
64    ///
65    /// Returns `None` if no icon by that name exists within the icon theme, or if parsing failed.
66    pub fn icon(&self, icon_name: impl AsRef<[u8]>) -> Option<Icon<'a>> {
67        let icon_name = icon_name.as_ref();
68        let hash = icon_str_hash(icon_name);
69        let n_buckets = self.hash.n_buckets.get();
70        let bucket = hash % n_buckets;
71
72        let icons = self.icon_chain(bucket)?.iter(self.bytes);
73
74        for icon in icons {
75            let Ok(name) = icon.name.str_at(self.bytes) else {
76                continue;
77            };
78
79            if name.to_bytes() == icon_name {
80                return Some(Icon {
81                    name,
82                    image_list: ImageList::from_icon(icon, self.bytes)?,
83                });
84            }
85        }
86
87        None
88    }
89
90    pub fn iter(&self) -> impl Iterator<Item = Icon<'a>> {
91        (0..self.hash.n_buckets.get())
92            .filter_map(|bucket| self.icon_chain(bucket))
93            .flat_map(|chain| chain.iter(self.bytes))
94            .filter_map(|icon| {
95                Some(Icon {
96                    name: icon.name.str_at(self.bytes).ok()?,
97                    image_list: ImageList::from_icon(icon, self.bytes)?,
98                })
99            })
100    }
101
102    fn icon_chain(&self, bucket: u32) -> Option<&'a raw::Icon> {
103        debug_assert!(bucket < self.hash.n_buckets.get());
104
105        let offset = self.hash.icon[bucket as usize];
106        // A bucket may be empty!
107        if offset.is_null() {
108            return None;
109        }
110
111        offset.at(self.bytes).ok()
112    }
113}
114
115/// List of directories in the icon theme with paths relative to the root of the icon theme.
116#[derive(derive_more::Debug, Copy, Clone)]
117pub struct DirectoryList<'a> {
118    #[debug(skip)]
119    #[allow(unused)] // clippy thinks this is unused but it isn't? maybe because of the Debug
120    bytes: &'a [u8],
121    pub raw_list: &'a raw::DirectoryList,
122}
123
124impl<'a> DirectoryList<'a> {
125    /// Returns the amount of directories in this list
126    #[inline(always)]
127    pub fn len(&self) -> u32 {
128        self.raw_list.n_directories.get()
129    }
130
131    /// Returns `true` if the list is empty
132    pub fn is_empty(&self) -> bool {
133        self.len() == 0
134    }
135
136    /// Access a directory by its index in the list.
137    ///
138    /// Returns `None` if the index larger than the length of the list.
139    pub fn dir(&self, idx: u32) -> Option<&'a Path> {
140        if idx >= self.len() {
141            return None;
142        }
143
144        self.raw_list.directory[idx as usize].path_at(self.bytes)
145    }
146
147    /// Returns an iterator over the directory list
148    pub fn iter(&self) -> impl Iterator<Item = &'a Path> {
149        (0..self.len()).filter_map(|idx| self.dir(idx))
150    }
151}
152
153/// An icon, identified by its name, and the list of images provided by the icon theme for this icon.
154#[derive(Debug, Copy, Clone)]
155pub struct Icon<'a> {
156    pub name: &'a CStr,
157    pub image_list: ImageList<'a>,
158}
159
160#[derive(derive_more::Debug, Copy, Clone)]
161pub struct ImageList<'a> {
162    #[debug(skip)]
163    bytes: &'a [u8],
164    pub raw_list: &'a raw::ImageList,
165}
166
167impl<'a> ImageList<'a> {
168    fn from_icon(icon: &raw::Icon, bytes: &'a [u8]) -> Option<ImageList<'a>> {
169        Some(Self {
170            bytes,
171            raw_list: icon.image_list.at(bytes).ok()?,
172        })
173    }
174
175    /// Returns the amount of images in this list
176    pub fn len(&self) -> u32 {
177        self.raw_list.n_images.get()
178    }
179
180    /// Returns `true` if the list is empty
181    pub fn is_empty(&self) -> bool {
182        self.len() == 0
183    }
184
185    /// Access an image by its index in the list.
186    ///
187    /// Returns `None` if the index larger than the length of the list, or if the image data
188    /// failed to parse.
189    pub fn image(&self, idx: u32) -> Option<Image<'a>> {
190        if idx >= self.len() {
191            return None;
192        }
193
194        let raw_image = &self.raw_list.images[idx as usize];
195
196        // TODO: how does the overhead of re-interpreting the header and directory list here over
197        // passing those down from the cache struct, or alternatively re-introducing the ref to cache?
198        let (header, _) = raw::Header::ref_from_prefix(self.bytes).ok()?;
199        let directory_list = header.directory_list.at(self.bytes).ok()?;
200        let directory = directory_list.directory[raw_image.directory_index.get() as usize]
201            .path_at(self.bytes)?;
202
203        let icon_flags = raw_image.icon_flags;
204
205        let mut image_data = None;
206
207        if raw_image.image_data.offset != 0 {
208            let &raw::ImageData {
209                image_pixel_data,
210                image_meta_data,
211                image_pixel_data_length,
212                image_pixel_data_type,
213            } = raw_image.image_data.at(self.bytes).ok()?;
214
215            image_data = Some(ImageData {
216                image_pixel_data: *image_pixel_data.at(self.bytes).ok()?,
217                image_meta_data: image_meta_data.at(self.bytes).ok()?,
218                image_pixel_data_type: *image_pixel_data_type.at(self.bytes).ok()?,
219                image_pixel_data_length: *image_pixel_data_length.at(self.bytes).ok()?,
220            });
221        }
222
223        Some(Image {
224            directory,
225            icon_flags,
226            image_data,
227        })
228    }
229
230    /// Returns an iterator over the image list
231    pub fn iter(&self) -> impl Iterator<Item = Image<'a>> {
232        (0..self.len()).filter_map(|idx| self.image(idx))
233    }
234}
235
236#[derive(derive_more::Debug, Copy, Clone)]
237pub struct Image<'a> {
238    pub directory: &'a Path,
239    pub icon_flags: raw::Flags,
240    pub image_data: Option<ImageData<'a>>,
241}
242
243#[derive(derive_more::Debug, Copy, Clone)]
244pub struct ImageData<'a> {
245    pub image_pixel_data: (), // TODO: what type is this?
246    pub image_meta_data: &'a raw::MetaData,
247    pub image_pixel_data_type: (),
248    pub image_pixel_data_length: (),
249}
250
251fn icon_str_hash(key: impl AsRef<[u8]>) -> u32 {
252    let bytes = key.as_ref();
253
254    if bytes.is_empty() {
255        return 0;
256    }
257
258    let mut h: u32 = bytes[0] as u32;
259    for &p in &bytes[1..] {
260        h = (h << 5).overflowing_sub(h).0.overflowing_add(p as u32).0;
261    }
262
263    h
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::raw::Offset;
270    use zerocopy::network_endian::U16;
271
272    // The included sample cache file was generated using the gtk-update-icon-cache utility
273    // from my system-installed hicolor theme.
274    static SAMPLE_INDEX_FILE: &[u8] = include_bytes!("../assets/icon-theme.cache");
275
276    #[test]
277    fn test_find_specific_icon() -> Result<(), Box<dyn Error>> {
278        let cache = IconCache::new_from_bytes(SAMPLE_INDEX_FILE)?;
279
280        assert_eq!(
281            cache.header,
282            &raw::Header {
283                major_version: U16::new(1),
284                minor_version: U16::new(0),
285                hash: Offset::new(12),
286                directory_list: Offset::new(35812)
287            }
288        );
289
290        assert_eq!(cache.hash.n_buckets, 251);
291
292        let icon = cache.icon("mpv").unwrap();
293
294        assert_eq!(icon.name.to_str(), Ok("mpv"));
295        assert_eq!(icon.image_list.len(), 5);
296
297        let image = &icon.image_list.image(0).unwrap();
298
299        assert_eq!(image.directory.to_str(), Some("scalable/apps"));
300        assert_eq!(
301            image.icon_flags,
302            raw::Flags::new(raw::Flags::HAS_SUFFIX_SVG)
303        );
304        assert!(image.image_data.is_none());
305
306        Ok(())
307    }
308
309    #[test]
310    fn test_icon_iter() -> Result<(), Box<dyn Error>> {
311        let cache = IconCache::new_from_bytes(SAMPLE_INDEX_FILE)?;
312
313        assert_eq!(cache.iter().count(), 563);
314
315        Ok(())
316    }
317
318    #[test]
319    fn test_image_list_iter() -> Result<(), Box<dyn Error>> {
320        let cache = IconCache::new_from_bytes(SAMPLE_INDEX_FILE)?;
321        let icon = cache.icon("mpv").unwrap();
322
323        let count = icon.image_list.iter().count();
324        assert_eq!(count, 5);
325
326        Ok(())
327    }
328
329    #[test]
330    fn test_directory_list_iter() -> Result<(), Box<dyn Error>> {
331        let cache = IconCache::new_from_bytes(SAMPLE_INDEX_FILE)?;
332        let dir_list = cache.directory_list;
333
334        assert_eq!(dir_list.len(), 59);
335
336        assert!(!cache.directory_list.is_empty());
337        assert_eq!(cache.directory_list.iter().count(), 59);
338
339        Ok(())
340    }
341
342    #[test]
343    fn icon_str_hash_empty() {
344        assert_eq!(icon_str_hash(""), 0);
345    }
346
347    #[test]
348    fn icon_str_hash_hello_world() {
349        assert_eq!(icon_str_hash("hello world"), 1794106052);
350    }
351
352    #[test]
353    fn icon_str_hash_sym() {
354        assert_eq!(icon_str_hash("preferences-other-symbolic") % 251, 243);
355    }
356
357    #[test]
358    fn image_size_correct() {
359        assert_eq!(size_of::<raw::Image>(), 8);
360    }
361}