linicon 2.3.0

Look up icons and icon theme info on Linux
Documentation
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! Look up icons and icon theme info on Linux.
//!
//! # Examples
//! Find an icon for Wireshark.
//! ```
//! let wireshark_icon = linicon::lookup_icon("wireshark").next();
//! println!("{:?}", wireshark_icon);
//! ```
//!
//! Find all of the icons for Wireshark from the Faenza theme
//! with a size of 64 and a 1x scale.
//! ```
//! use linicon::lookup_icon;
//! let wireshark_icons: Vec<_> = lookup_icon("wireshark")
//!     .from_theme("Faenza")
//!     .with_size(64)
//!     .with_scale(1)
//!     .use_fallback_themes(false)
//!     .collect();
//! println!("{:#?}", wireshark_icons);
//! ```

pub mod errors;
mod parser;

pub use errors::{LiniconError, Result};
use file_locker::FileLock;
#[cfg(feature = "system-theme")]
pub use linicon_theme::get_icon_theme as get_system_theme;
use memmap2::Mmap;
use parser::{parse_index, Directory};
use std::{
    cmp::{Eq, PartialEq},
    collections::{HashMap, VecDeque},
    env,
    iter::Iterator,
    path::{Path, PathBuf},
    vec::IntoIter,
};

const DEFAULT_THEME: &str = "hicolor";

fn search_paths() -> Vec<PathBuf> {
    let mut paths = Vec::new();
    if let Ok(path) = env::var("HOME") {
        if !path.is_empty() {
            paths.push(Path::new(&path).join(".icons"));
        }
    }
    match env::var("XDG_DATA_DIRS") {
        Ok(data_dirs) if !data_dirs.is_empty() => {
            for path in data_dirs.split(':') {
                if !path.is_empty() {
                    paths.push(Path::new(&path).join("icons"));
                }
            }
        }
        _ => {
            paths.push(Path::new("/usr/local/share/icons").to_owned());
            paths.push(Path::new("/usr/share/icons").to_owned());
        }
    }
    paths.push(Path::new("/usr/share/pixmaps").to_owned());
    paths
}

fn find_theme_dirs(
    dir_name: &str,
    search_paths: &[PathBuf],
    extra_search_paths: Option<&Vec<PathBuf>>,
) -> Vec<PathBuf> {
    if let Some(extra_search_paths) = extra_search_paths {
        extra_search_paths
            .iter()
            .chain(search_paths.iter())
            .filter_map(|search_path| {
                let path = search_path.join(dir_name);
                if path.as_path().exists() {
                    Some(path)
                } else {
                    None
                }
            })
            .collect()
    } else {
        search_paths
            .iter()
            .filter_map(|search_path| {
                let path = search_path.join(dir_name);
                if path.as_path().exists() {
                    Some(path)
                } else {
                    None
                }
            })
            .collect()
    }
}

fn find_index(theme_dirs: &[PathBuf]) -> Option<PathBuf> {
    theme_dirs.iter().find_map(|dir| {
        let index_path = dir.join("index.theme");
        if index_path.exists() {
            Some(index_path)
        } else {
            None
        }
    })
}

/// The type of icon returned i.e. file type
///
/// *Note*: These are the only image file formats supported by the
/// Freedesktop standard.
#[derive(Debug, Eq, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum IconType {
    PNG,
    SVG,
    XMP,
}

/// A path to an icon and meta data about that icon
#[derive(Debug, Eq, PartialEq)]
pub struct IconPath {
    /// The path to the icon
    pub path: PathBuf,
    /// The name of the theme to icon is from
    pub theme: String,
    /// The type of icon i.e. the file type
    pub icon_type: IconType,
    /// The minimum size of the icon
    pub min_size: u16,
    /// The maximum size of the icon
    pub max_size: u16,
    /// The scale the icon is used for
    pub scale: u16,
}

/// An iterator over the icons that have the name given to
/// [`lookup_icon`](fn.lookup_icon.html).
#[derive(Debug)]
pub struct IconIter<'a> {
    theme_name: Option<String>,
    icon_name: String,
    size: Option<u16>,
    scale: Option<u16>,
    extra_search_paths: Option<Vec<PathBuf>>,
    state: Option<IconIterState<'a>>,
    failed: bool,
    do_fallback: bool,
}

#[derive(Debug)]
struct IconIterState<'a> {
    theme_name: String,
    /// Directories for the current theme
    theme_dirs: Vec<PathBuf>,
    /// Locked index file handle
    #[allow(dead_code)]
    file_lock: FileLock,
    /// Index file mem-map
    #[allow(dead_code)]
    mmap: Mmap,
    /// Iterator over directories in the index file
    dir_iter: IntoIter<Directory<'a>>,
    /// List of fallback themes
    fallbacks: VecDeque<String>,
}

impl<'a> IconIterState<'a> {
    fn init(
        theme_name: String,
        extra_search_paths: Option<&Vec<PathBuf>>,
    ) -> Result<Self> {
        let sps = search_paths();

        let theme_dirs = find_theme_dirs(&theme_name, &sps, extra_search_paths);
        if theme_dirs.is_empty() {
            return Err(LiniconError::ThemeNotFound(theme_name));
        }

        let index_file = find_index(&theme_dirs)
            .ok_or_else(|| LiniconError::IndexFileNotFound(theme_name.clone()))?;
        let file_lock = FileLock::new(&index_file)
            .blocking(true)
            .lock()
            .map_err(|e| LiniconError::OpenIndex {
                path: index_file.clone(),
                source: e,
            })?;
        // SAFETY: This is safe because the file is locked such that other
        // processes can't write to the file while we have it open
        let mmap = unsafe { Mmap::map(&file_lock.file) }.map_err(|e| {
            LiniconError::OpenIndex {
                path: index_file.clone(),
                source: e,
            }
        })?;

        // SAFETY: the Mmap struct should never outlive dir_iter
        let (index_header, dirs) =
            parse_index(unsafe { std::mem::transmute(mmap.as_ref()) })
                .map_err(|e| LiniconError::ParseIndex {
                    path: index_file,
                    source: e,
                })?;

        // Parse fallback themes
        let fallbacks = match &index_header.inherits {
            Some(inherits) => {
                let mut fallbacks = VecDeque::new();
                for theme in inherits.split(',') {
                    if !theme.is_empty() {
                        fallbacks.push_back(theme.to_owned());
                    }
                }
                fallbacks
            }
            None => VecDeque::new(),
        };

        Ok(IconIterState {
            theme_name,
            theme_dirs,
            file_lock,
            mmap,
            dir_iter: dirs.into_iter(),
            fallbacks,
        })
    }
}

impl<'a> IconIter<'a> {
    fn lookup_icon(&mut self) -> Result<Option<IconPath>> {
        let state = self.state.as_mut().unwrap();
        for dir in &mut state.dir_iter {
            if (self.scale.is_none() || self.scale.unwrap() == dir.scale)
                && (self.size.is_none()
                    || self.size.unwrap() <= dir.max_size
                        && self.size.unwrap() >= dir.min_size)
            {
                let dir_name = match std::str::from_utf8(dir.name) {
                    Ok(dir_name) => dir_name,
                    Err(_e) => {
                        // TODO figure out what to do with this error
                        continue;
                    }
                };
                for theme_dir in &mut state.theme_dirs {
                    // dbg!(&theme_dir);
                    let path = theme_dir
                        .join(dir_name)
                        .join(format!("{}.svg", self.icon_name));
                    // dbg!(&path);
                    if path.as_path().exists() {
                        return Ok(Some(IconPath {
                            path,
                            theme: state.theme_name.clone(),
                            icon_type: IconType::SVG,
                            min_size: dir.min_size,
                            max_size: dir.max_size,
                            scale: dir.scale,
                        }));
                    }
                    let path = theme_dir
                        .join(dir_name)
                        .join(format!("{}.png", self.icon_name));
                    if path.as_path().exists() {
                        return Ok(Some(IconPath {
                            path,
                            theme: state.theme_name.clone(),
                            icon_type: IconType::PNG,
                            min_size: dir.min_size,
                            max_size: dir.max_size,
                            scale: dir.scale,
                        }));
                    }
                    let path = theme_dir
                        .join(dir_name)
                        .join(format!("{}.xpm", self.icon_name));
                    if path.as_path().exists() {
                        return Ok(Some(IconPath {
                            path,
                            theme: state.theme_name.clone(),
                            icon_type: IconType::XMP,
                            min_size: dir.min_size,
                            max_size: dir.max_size,
                            scale: dir.scale,
                        }));
                    }
                }
            }
        }
        if !self.do_fallback {
            return Ok(None);
        }
        if state.theme_name == DEFAULT_THEME {
            Ok(None)
        } else {
            let fallback = match state.fallbacks.pop_front() {
                Some(name) => name,
                None => DEFAULT_THEME.to_owned(),
            };
            self.state = match IconIterState::init(
                fallback,
                self.extra_search_paths.as_ref(),
            ) {
                Ok(state) => Some(state),
                Err(e) => {
                    self.failed = true;
                    return Err(e);
                }
            };
            self.lookup_icon()
        }
    }

    /// Get icons from this theme
    ///
    /// If this option is not set, the iterator will search in the user's icon
    /// theme if the `system-theme` feature is enabled (on by default) and the
    /// user's theme can be determined otherwise the default hicolor theme will
    /// be used.
    ///
    /// Will panic if called after calling next.
    #[allow(clippy::wrong_self_convention)]
    pub fn from_theme(mut self, theme_name: impl AsRef<str>) -> Self {
        if self.state.is_some() {
            panic!("linicon: Cannot change icon iterator settings after iteration has started");
        }
        self.theme_name = Some(theme_name.as_ref().to_owned());
        self
    }

    /// Search for an icon of the given size.
    ///
    /// Will panic if called after calling next.
    pub fn with_size(mut self, size: u16) -> Self {
        if self.state.is_some() {
            panic!("linicon: Cannot change icon iterator settings after iteration has started");
        }
        self.size = Some(size);
        self
    }

    /// Search for an icon for use with the given UI scale.
    ///
    /// Will panic if called after calling next.
    pub fn with_scale(mut self, scale: u16) -> Self {
        if self.state.is_some() {
            panic!("linicon: Cannot change icon iterator settings after iteration has started");
        }
        self.scale = Some(scale);
        self
    }

    /// Add additional paths to search for icon themes in.  These paths will be used first.
    ///
    /// If the feature `expand-paths` is turn on (off by default) environment
    /// variable and `~`s in paths will be expanded to their values. When this
    /// feature is enabled, if expanding a search path this function will return
    /// an error. See
    /// [`shellexpand::full`](https://docs.rs/shellexpand/2.0.0/shellexpand/fn.full.html).
    ///
    /// This function will never return an error if the `expand-paths` feature is turned off.
    ///
    /// Will panic if called after calling next.
    pub fn with_search_paths(
        mut self,
        extra_search_paths: &[impl AsRef<str>],
    ) -> Result<Self> {
        if self.state.is_some() {
            panic!("linicon: Cannot change icon iterator settings after iteration has started");
        }
        self.extra_search_paths =
            Some(expand_search_paths(extra_search_paths)?);
        Ok(self)
    }

    /// Whether to use fallback themes (on by default).
    ///
    /// Normally, once the iterator has exhausted icons that match the given parameters,
    /// the iterator will search through the icon theme's fallback themes.  Setting this
    /// to false will prevent this behavior so that the icons will all be from the given theme.
    ///
    /// Will panic if called after calling next.
    pub fn use_fallback_themes(mut self, use_fallback: bool) -> Self {
        if self.state.is_some() {
            panic!("linicon: Cannot change icon iterator settings after iteration has started");
        }
        self.do_fallback = use_fallback;
        self
    }
}

impl<'a> Iterator for IconIter<'a> {
    type Item = Result<IconPath>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.failed {
            None
        } else {
            // Initialized state if it isn't
            if self.state.is_none() {
                self.state = match IconIterState::init(
                    // Get the theme or used the default theme
                    self.theme_name.take().unwrap_or_else(|| {
                        #[cfg(feature = "system-theme")]
                        match linicon_theme::get_icon_theme() {
                            Some(theme_name) => theme_name,
                            None => DEFAULT_THEME.to_owned(),
                        }
                        #[cfg(not(feature = "system-theme"))]
                        DEFAULT_THEME.to_owned()
                    }),
                    self.extra_search_paths.as_ref(),
                ) {
                    Ok(state) => Some(state),
                    Err(e) => {
                        self.failed = true;
                        return Some(Err(e));
                    }
                };
            }
            match self.lookup_icon() {
                Ok(icon_path) => icon_path.map(Ok),
                Err(e) => Some(Err(e)),
            }
        }
    }
}

/// Lookup icons with by name, size, and scale in the given theme or one of its
/// fallbacks.
///
/// By default, if the `system-theme` feature is enabled (on by default) this
/// will search through the user's icon theme if it can be determined.
/// Otherwise, it will search through the default theme `hicolor`.  To search
/// through a specific theme, use
/// [`IconIter::from_theme`](struct.IconIter.html#method.from_theme).
///
/// As per the [FreeDesktop Icon Theme
/// specification](https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html),
/// linicon will search for icon themes the the following directories in order:
/// - `$HOME/.icons`
/// - `$XDG_DATA_DIRS/icons` (each path in list followed by `/icons`)
/// - `/usr/share/pixmaps`
///
/// If you need to specify additional search paths use
/// [`IconIter::with_extra_paths`](struct.IconIter.html#method.with_search_paths)
///
/// # Returns
/// Returns an iterator over any matching icons.  The iterator return matching
/// icons in any fallback themes after ones in the main theme are exhausted unless
/// [`IconIter::use_fallback_themes`](struct.IconIter.html#method.use_fallback_themes)
/// is used.
///
/// # Errors
/// The iterator may return an error if:
/// - The selected theme isn't installed(correctly)
/// - There is an error parsing a theme's index file
/// - Finding a fallback theme fails
/// - The above for getting the fall theme
///
/// *Note*: The iterator will return `None` after an error.
///
/// # Examples
/// Find an icon for Wireshark.
/// ```
/// let wireshark_icon = linicon::lookup_icon("wireshark").next();
/// println!("{:?}", wireshark_icon);
/// ```
///
/// Find all of the icons for Wireshark from the Faenza theme
/// with a size of 64 and a 1x scale.
/// ```
/// use linicon::lookup_icon;
/// let wireshark_icons: Vec<_> = lookup_icon("wireshark")
///     .from_theme("Faenza")
///     .with_size(64)
///     .with_scale(1)
///     .use_fallback_themes(false)
///     .collect();
/// println!("{:#?}", wireshark_icons);
/// ```
pub fn lookup_icon<'a>(icon_name: impl AsRef<str>) -> IconIter<'a> {
    IconIter {
        icon_name: icon_name.as_ref().to_owned(),
        theme_name: None,
        size: None,
        scale: None,
        extra_search_paths: None,
        state: None,
        failed: false,
        do_fallback: true,
    }
}

/// Works the same as [`lookup_icon`](fn.lookup_icon.html) expect you can
/// provide a list of additional paths to the default list of paths in with to
/// search for icon themes.
///
///
/// In addition to the possible errors returned by
/// [`lookup_icon`](fn.lookup_icon.html), this function may also return an error
/// if expanding one of the search paths fails when the `expand-paths` is
/// enabled.

#[cfg(feature = "expand-paths")]
fn expand_search_paths(
    search_paths: &[impl AsRef<str>],
) -> Result<Vec<PathBuf>> {
    search_paths
        .iter()
        .map(|path| {
            shellexpand::full(path)
                .map(|expanded| Path::new(expanded.as_ref()).to_owned())
                .map_err(LiniconError::from)
        })
        .collect::<Result<_>>()
}

#[cfg(not(feature = "expand-paths"))]
fn expand_search_paths(
    search_paths: &[impl AsRef<str>],
) -> Result<Vec<PathBuf>> {
    Ok(search_paths
        .iter()
        .map(|expanded| Path::new(expanded.as_ref()).to_owned())
        .collect())
}

/// Metadata about a theme
#[derive(Debug, PartialEq, Eq)]
pub struct Theme {
    /// Name (in the file system) of the theme
    pub name: String,
    /// Display name of the theme (from the index file)
    pub display_name: String,
    /// Paths to the directories where this themes icons live
    pub paths: Vec<PathBuf>,
    /// Themes that this theme inherit from (will fall back to these)
    pub inherits: Option<Vec<String>>,
    /// Comment for this theme
    pub comment: Option<String>,
}

/// Get metadata about installed themes.
///
/// Will ignore a theme if errors occur getting its metadata.
pub fn themes() -> Vec<Theme> {
    _themes(None)
}

/// Get metadata about installed themes, with additional search paths.
///
/// Will ignore a theme if errors occur getting its metadata.
///
/// # Errors
/// The function will return an error if:
/// - If the `expand-paths` feature is enabled, expanding the search paths
///   fails.  See [`shellexpand::full`](https://docs.rs/shellexpand/2.0.0/shellexpand/fn.full.html)
///
/// *Note*: This function will always return Ok if `expand-paths` feature is disabled (not on by default)
pub fn themes_with_extra_paths(
    extra_search_paths: &[impl AsRef<str>],
) -> Result<Vec<Theme>> {
    Ok(_themes(Some(&expand_search_paths(extra_search_paths)?)))
}

fn _themes(extra_search_paths: Option<&Vec<PathBuf>>) -> Vec<Theme> {
    let search_paths = search_paths();
    let mut themes: HashMap<String, Vec<PathBuf>> = HashMap::new();
    for path in &search_paths {
        for entry in match std::fs::read_dir(&path) {
            Ok(iter) => iter,
            Err(_) => continue,
        } {
            match entry {
                Ok(entry) => match entry.file_name().into_string() {
                    Ok(s) => {
                        themes
                            .entry(s)
                            .and_modify(|e| e.push(entry.path()))
                            .or_insert_with(|| vec![entry.path()]);
                    }
                    Err(_) => continue,
                },
                Err(_) => continue,
            }
        }
    }
    themes
        .into_iter()
        .filter_map(|(name, paths)| {
            let theme_dirs =
                find_theme_dirs(&name, &search_paths, extra_search_paths);
            match find_index(&theme_dirs) {
                Some(index_file) => {
                    let file_lock = match FileLock::new(&index_file)
                        .writeable(false)
                        .blocking(true)
                        .lock()
                    {
                        Ok(f) => f,
                        Err(_) => return None,
                    };
                    // SAFETY: This is safe because the file is locked such that
                    // other processes can't write to the file while we have it open
                    let mmap = match unsafe { Mmap::map(&file_lock.file) } {
                        Ok(v) => v,
                        Err(_) => return None,
                    };

                    let (index_header, _) = match parse_index(mmap.as_ref()) {
                        Ok(v) => v,
                        Err(_) => return None,
                    };
                    Some(Theme {
                        name,
                        paths,
                        display_name: index_header.name,
                        inherits: index_header.inherits.map(|s| {
                            s.split(',').map(|s| s.to_owned()).collect()
                        }),
                        comment: index_header.comment,
                    })
                }
                None => None,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn do_lookup() {
        let wireshark_icons: Vec<_> = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_size(64)
            .with_scale(1)
            .collect();
        assert_eq!(
            wireshark_icons
                .into_iter()
                .filter(Result::is_ok)
                .map(Result::unwrap)
                .filter(|ic| ic.path.display().to_string().contains("Faenza"))
                .count(),
            2
        );
    }

    #[test]
    fn do_lookup_no_size() {
        let wireshark_icons: Vec<_> = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_scale(1)
            .collect();
        assert_eq!(
            wireshark_icons
                .into_iter()
                .filter(Result::is_ok)
                .map(Result::unwrap)
                .filter(|ic| ic.path.display().to_string().contains("Faenza"))
                .count(),
            8
        );
    }

    #[test]
    fn do_lookup_no_scale() {
        let wireshark_icons: Vec<_> = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_size(64)
            .collect();
        assert_eq!(
            wireshark_icons
                .into_iter()
                .filter(Result::is_ok)
                .map(Result::unwrap)
                .filter(|ic| ic.path.display().to_string().contains("Faenza"))
                .count(),
            2
        );
    }

    #[test]
    fn do_lookup_no_size_scale() {
        let wireshark_icons: Vec<_> =
            lookup_icon("wireshark").from_theme("Faenza").collect();
        assert_eq!(
            wireshark_icons
                .into_iter()
                .filter(Result::is_ok)
                .map(Result::unwrap)
                .filter(|ic| ic.path.display().to_string().contains("Faenza"))
                .count(),
            8
        );
    }

    #[test]
    fn move_threads() {
        let mut iter = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_size(64)
            .with_scale(1);
        let first = iter.next();
        assert_eq!(
            first.unwrap().unwrap().path.display().to_string(),
            "/usr/share/icons/Faenza/apps/64/wireshark.png"
        );
        assert!(std::thread::spawn(move || {
            let second = iter.next();
            second.unwrap().unwrap().path.display().to_string()
                == "/usr/share/icons/Faenza/apps/scalable/wireshark.svg"
        })
        .join()
        .unwrap());
    }

    #[test]
    fn move_iter() {
        let mut iter1 = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_size(64)
            .with_scale(1);
        let mut iter2 = lookup_icon("wireshark")
            .from_theme("Faenza")
            .with_size(64)
            .with_scale(1);
        std::mem::swap(&mut iter1, &mut iter2);
        std::mem::drop(iter1);
        assert_eq!(
            iter2
                .into_iter()
                .filter(Result::is_ok)
                .map(Result::unwrap)
                .filter(|ic| ic.path.display().to_string().contains("Faenza"))
                .count(),
            2
        );
    }

    #[test]
    fn get_themes() {
        let theme = themes()
            .into_iter()
            .find(|theme| theme.name == "Faenza")
            .unwrap();
        assert_eq!(
            theme,
            Theme {
                name: "Faenza".to_owned(),
                display_name: "Faenza".to_owned(),
                paths: vec![Path::new("/usr/share/icons/Faenza").to_owned()],
                inherits: Some(vec!["gnome".to_owned(), "hicolor".to_owned()]),
                comment: Some(
                    "Icon theme project with tilish style, by Tiheum"
                        .to_owned()
                ),
            }
        );
    }

    #[cfg(feature = "expand-paths")]
    mod expand_paths {
        use super::*;

        #[test]
        fn lookup_and_expand() {
            let other_paths = ["~/.local/share"];
            let wireshark_icons: Vec<_> = lookup_icon("wireshark")
                .from_theme("Faenza")
                .with_size(64)
                .with_scale(1)
                .with_search_paths(&other_paths)
                .unwrap()
                .collect();
            assert_eq!(
                wireshark_icons
                    .into_iter()
                    .filter(Result::is_ok)
                    .map(Result::unwrap)
                    .filter(|ic| ic
                        .path
                        .display()
                        .to_string()
                        .contains("Faenza"))
                    .count(),
                2
            );
        }
    }
}