diff --git a/Cargo.toml b/Cargo.toml index 3662cca..bf5f8cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ edition = "2021" dirs = "4.0.0" rust-ini = "0.18.0" thiserror = "1.0.31" +once_cell = "1.10.0" [dev-dependencies] speculoos = "0.9.0" -anyhow = "1.0.57" \ No newline at end of file +anyhow = "1.0.57" +linicon = "2.3.0" \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a0b887f..daa32e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,54 @@ -pub mod theme; \ No newline at end of file +use crate::theme::{try_build_icon_path, FALL_BACK_THEMES, THEMES}; +use std::path::PathBuf; + +pub mod theme; + +pub fn lookup(name: &str, size: u16, scale: u16, theme: &str) -> Option { + if let Some(theme) = THEMES.get(theme) { + let icon = theme.try_get_icon(name, size, scale); + if icon.is_some() { + return icon; + } + } + + for theme in FALL_BACK_THEMES.iter() { + let icon = theme.try_get_icon(name, size, scale); + if icon.is_some() { + return icon; + } + } + + try_build_icon_path(name, "/usr/share/pixmaps") +} + +#[cfg(test)] +mod test { + use crate::lookup; + use speculoos::prelude::*; + + #[test] + fn compare_to_linincon() { + let lin_wireshark = linicon::lookup_icon("wireshark") + .next() + .unwrap() + .unwrap() + .path; + + let wireshark = lookup("wireshark", 16, 1, "Papirus"); + + assert_that!(wireshark).is_some().is_equal_to(lin_wireshark) + } + + #[test] + fn compare_to_linicon_in_pixmap() { + let archlinux_logo = linicon::lookup_icon("archlinux-logo").next(); + + assert_that!(archlinux_logo).is_some(); + + let archlinux_logo = lookup("archlinux-logo", 16, 1, "Papirus"); + + assert_that!(archlinux_logo) + .is_some() + .has_file_name("/usr/share/pixmaps/archlinux-logo.png"); + } +} diff --git a/src/theme/directories.rs b/src/theme/directories.rs new file mode 100644 index 0000000..61a3ae9 --- /dev/null +++ b/src/theme/directories.rs @@ -0,0 +1,84 @@ +#[derive(Debug)] +pub struct Directory<'a> { + pub name: &'a str, + pub size: i16, + pub scale: i16, + pub context: Option<&'a str>, + pub type_: DirectoryType, + pub maxsize: i16, + pub minsize: i16, + pub threshold: i16, +} + +impl Directory<'_> { + pub fn match_size(&self, size: u16, scale: u16) -> bool { + let scale = scale as i16; + let size = size as i16; + + if self.scale != scale { + false + } else { + match self.type_ { + DirectoryType::Fixed => self.size == size, + DirectoryType::Scalable => self.minsize <= size && size <= self.maxsize, + DirectoryType::Threshold => { + self.size - self.threshold <= size && size <= self.size + self.threshold + } + } + } + } + + pub fn directory_size_distance(&self, size: u16, scale: u16) -> i16 { + let scaled_size = self.size * self.scale; + let min_scaled_size = self.minsize * self.scale; + let max_scaled_size = self.maxsize * self.scale; + let scale = scale as i16; + let size = size as i16; + let scaled_requested_size = size * scale; + + match self.type_ { + DirectoryType::Fixed => scaled_size - scaled_requested_size, + DirectoryType::Scalable => { + if scaled_requested_size < min_scaled_size { + min_scaled_size - scaled_requested_size + } else if scaled_requested_size < max_scaled_size { + scaled_requested_size - max_scaled_size + } else { + 0 + } + } + DirectoryType::Threshold => { + if scaled_requested_size < (self.size - self.threshold) * scale { + min_scaled_size - scaled_requested_size + } else if scaled_requested_size > (self.size + self.threshold) * scale { + scaled_requested_size - max_scaled_size + } else { + 0 + } + } + } + } +} + +#[derive(Debug)] +pub enum DirectoryType { + Fixed, + Scalable, + Threshold, +} + +impl Default for DirectoryType { + fn default() -> Self { + Self::Threshold + } +} + +impl From<&str> for DirectoryType { + fn from(value: &str) -> Self { + match value { + "Fixed" => DirectoryType::Fixed, + "Scalable" => DirectoryType::Scalable, + _ => DirectoryType::Threshold, + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index a616fd8..895356f 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -1,123 +1,172 @@ use crate::theme::error::ThemeError; -use dirs::{data_dir, home_dir}; +use crate::theme::paths::{ThemePath, FALLBACK_PATHS}; use ini::Ini; -use std::borrow::Cow; +use once_cell::sync::Lazy; +use paths::BASE_PATHS; +use std::collections::BTreeMap; use std::fmt::{Debug, Formatter}; -use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +pub mod directories; pub mod error; +pub mod parse; +pub mod paths; -type Result = std::result::Result; +type Result = std::result::Result; -const HICOLOR: &str = "/usr/share/pixmaps"; +pub static THEMES: Lazy> = + Lazy::new(|| get_all_themes().expect("Failed to get theme paths")); -#[derive(Debug)] -struct ThemePath(PathBuf); +pub static FALL_BACK_THEMES: Lazy> = + Lazy::new(|| fallback_themes().expect("Failed to get theme paths")); -struct ThemeIndex(Ini); +pub struct Theme { + path: ThemePath, + index: Ini, +} -impl Debug for ThemeIndex { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let mut content = vec![]; - self.0.write_to(&mut content).expect("Write error"); - let content = String::from_utf8_lossy(&content); - writeln!(f, "ThemeIndex({content:?})") +impl Theme { + pub fn try_get_icon(&self, name: &str, size: u16, scale: u16) -> Option { + self.try_get_icon_exact_size(name, size, scale) + .or(self.try_get_icon_closest_size(name, size, scale)) + } + + fn try_get_icon_exact_size(&self, name: &str, size: u16, scale: u16) -> Option { + self.match_size(size, scale) + .iter() + .find_map(|path| try_build_icon_path(name, path)) + } + + fn match_size(&self, size: u16, scale: u16) -> Vec { + let dirs = self.get_all_directories(); + + dirs.iter() + .filter(|directory| directory.match_size(size, scale)) + .map(|dir| dir.name) + .map(|dir| self.path().join(dir)) + .collect() + } + + fn try_get_icon_closest_size(&self, name: &str, size: u16, scale: u16) -> Option { + self.closest_match_size(size, scale) + .iter() + .find_map(|path| try_build_icon_path(name, path)) + } + + fn closest_match_size(&self, size: u16, scale: u16) -> Vec { + let dirs = self.get_all_directories(); + + dirs.iter() + .filter(|directory| directory.directory_size_distance(size, scale) < i16::MAX) + .map(|dir| dir.name) + .map(|dir| self.path().join(dir)) + .collect() + } + + fn path(&self) -> &PathBuf { + &self.path.0 } } -impl ThemePath { - fn name(&self) -> Cow<'_, str> { - // Unwrapping is safe here, we just got the path from [`list_icon_themes`] - self.0.file_name().unwrap().to_string_lossy() +pub(super) fn try_build_icon_path>(name: &str, path: P) -> Option { + let path = path.as_ref(); + let png = path.join(format!("{name}.png")); + if png.exists() { + return Some(png); } - fn index(&self) -> Result { - let index = self.0.join("index.theme"); - - if !index.exists() { - return Err(ThemeError::ThemeIndexNotFound(index)); - } - - let index = Ini::load_from_file(index)?; - - Ok(ThemeIndex(index)) + let svg = path.join(format!("{name}.svg")); + if svg.exists() { + return Some(svg); } -} -/// Look in $HOME/.icons (for backwards compatibility), in $XDG_DATA_DIRS/icons and in /usr/share/pixmaps (in that order). -/// Paths that are not found are filtered out. -fn icon_theme_base_paths() -> Vec { - let home_icon_dir = home_dir().expect("No $HOME directory").join(".icons"); - let usr_data_dir = data_dir().expect("No $XDG_DATA_DIR").join("icons"); - let xdg_data_dirs_local = PathBuf::from("/usr/local/share/").join("icons"); - let xdg_data_dirs = PathBuf::from("/usr/share/").join("icons"); + let xmp = path.join(format!("{name}.xmp")); - [ - home_icon_dir, - usr_data_dir, - xdg_data_dirs_local, - xdg_data_dirs, - ] - .into_iter() - .filter(|p| p.exists()) - .collect() + if xmp.exists() { + return Some(xmp); + } + + None } // Iter through the base paths and get all theme directories -fn list_icon_themes() -> io::Result> { - let mut icon_theme_path = vec![]; - for theme_base_dir in icon_theme_base_paths().iter() { +fn get_all_themes() -> Result> { + let mut icon_themes = BTreeMap::new(); + for theme_base_dir in BASE_PATHS.iter() { for entry in theme_base_dir.read_dir()? { let entry = entry?; - let has_index = entry.path().join("index.theme").exists(); - if entry.path().is_dir() && has_index { - icon_theme_path.push(ThemePath(entry.path())); + if let Some(theme) = Theme::from_path(entry.path()) { + let name = entry.file_name().to_string_lossy().to_string(); + icon_themes.insert(name, theme); } } } - Ok(icon_theme_path) + Ok(icon_themes) +} + +fn fallback_themes() -> Result> { + let mut icon_themes = vec![]; + for theme_base_dir in FALLBACK_PATHS.iter() { + for entry in theme_base_dir.read_dir()? { + let entry = entry?; + if let Some(theme) = Theme::from_path(entry.path()) { + icon_themes.push(theme); + } + } + } + + Ok(icon_themes) +} + +pub fn theme_names() -> Vec<&'static str> { + THEMES + .values() + .map(|path| &path.index) + .filter_map(|index| { + index + .section(Some("Icon Theme")) + .and_then(|section| section.get("Name").map(|s| s)) + }) + .collect() +} + +impl Theme { + fn from_path>(path: P) -> Option { + let path = path.as_ref(); + + let has_index = path.join("index.theme").exists(); + + if !has_index || !path.is_dir() { + return None; + } + + let path = ThemePath(path.into()); + + match path.index() { + Ok(index) => Some(Theme { path, index }), + Err(_) => None, + } + } +} + +impl Debug for Theme { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut content = vec![]; + self.index.write_to(&mut content).expect("Write error"); + let content = String::from_utf8_lossy(&content); + writeln!(f, "ThemeIndex{{path: {:?}, index: {content:?}}}", self.path) + } } #[cfg(test)] mod test { - use crate::theme::{icon_theme_base_paths, list_icon_themes}; - use anyhow::Result; - use speculoos::prelude::*; + use crate::THEMES; #[test] - fn should_get_theme_paths_ordered() { - let base_paths = icon_theme_base_paths(); - - assert_that!(base_paths).is_not_empty() - } - - #[test] - fn should_get_icon_theme_paths_ordered() -> Result<()> { - let themes = list_icon_themes()?; - - assert_that!(themes).is_not_empty(); - Ok(()) - } - - #[test] - fn should_read_theme_index() -> Result<()> { - let paths = list_icon_themes()?; - - for theme_path in paths { - assert_that!(theme_path.index()).is_ok(); - } - - Ok(()) - } - - #[test] - fn should_get_theme_name() -> Result<()> { - let paths = list_icon_themes()?; - - for theme_path in paths { - assert_that!(theme_path.name().len()).is_greater_than(0); - } - - Ok(()) + fn get_one_icon() { + let theme = THEMES.get("Adwaita").unwrap(); + println!( + "{:?}", + theme.try_get_icon_exact_size("edit-delete-symbolic", 24, 1) + ); } } diff --git a/src/theme/parse.rs b/src/theme/parse.rs new file mode 100644 index 0000000..1fcd014 --- /dev/null +++ b/src/theme/parse.rs @@ -0,0 +1,74 @@ +use crate::theme::directories::{Directory, DirectoryType}; +use crate::theme::Theme; +use ini::Properties; + +impl Theme { + pub(super) fn get_all_directories(&self) -> Vec { + let dir_names = self.directories().unwrap_or(vec![]); + let mut dirs = vec![]; + for dir in dir_names { + let dir = self.get_directory(dir); + if let Some(dir) = dir { + dirs.push(dir); + } + } + + dirs + } + + fn scaled_directories(&self) -> Option> { + self.get_icon_theme_section() + .and_then(|props| props.get("ScaledDirectories")) + .map(|dirs| dirs.split(',').collect()) + } + + fn get_icon_theme_section(&self) -> Option<&Properties> { + self.index.section(Some("Icon Theme")) + } + + pub fn inherits(&self) -> Option<&str> { + self.get_icon_theme_section() + .and_then(|props| props.get("Inherits")) + } + + fn directories(&self) -> Option> { + self.index + .section(Some("Icon Theme")) + .and_then(|props| props.get("Directories")) + .map(|dirs| dirs.split(',').collect()) + } + + fn get_directory<'a>(&'a self, name: &'a str) -> Option { + self.index.section(Some(name)).map(|props| { + let size = props + .get("Size") + .and_then(|size| str::parse(size).ok()) + .expect("Size not found for icon"); + Directory { + name, + size, + scale: props + .get("Scale") + .and_then(|scale| str::parse(scale).ok()) + .unwrap_or(1), + context: props.get("Context"), + type_: props + .get("Type") + .map(DirectoryType::from) + .unwrap_or_default(), + maxsize: props + .get("MaxSize") + .and_then(|max| str::parse(max).ok()) + .unwrap_or(size), + minsize: props + .get("MinSize") + .and_then(|min| str::parse(min).ok()) + .unwrap_or(size), + threshold: props + .get("Threshold") + .and_then(|thrsh| str::parse(thrsh).ok()) + .unwrap_or(2), + } + }) + } +} diff --git a/src/theme/paths.rs b/src/theme/paths.rs new file mode 100644 index 0000000..266a5c9 --- /dev/null +++ b/src/theme/paths.rs @@ -0,0 +1,72 @@ +use crate::theme; +use crate::theme::error::ThemeError; +use dirs::{data_dir, home_dir}; +use ini::Ini; +use once_cell::sync::Lazy; +use std::path::PathBuf; + +pub(crate) static BASE_PATHS: Lazy> = Lazy::new(|| icon_theme_base_paths()); +pub(crate) static FALLBACK_PATHS: Lazy> = Lazy::new(|| { + vec![ + data_dir() + .expect("Failed to get DATA_DIR") + .join("icons/hicolor"), + PathBuf::from("usr/share/icons/hicolor"), + ] +}); + +/// Look in $HOME/.icons (for backwards compatibility), in $XDG_DATA_DIRS/icons and in /usr/share/pixmaps (in that order). +/// Paths that are not found are filtered out. +fn icon_theme_base_paths() -> Vec { + let home_icon_dir = home_dir().expect("No $HOME directory").join(".icons"); + let usr_data_dir = data_dir().expect("No $XDG_DATA_DIR").join("icons"); + let xdg_data_dirs_local = PathBuf::from("/usr/local/share/").join("icons"); + let xdg_data_dirs = PathBuf::from("/usr/share/").join("icons"); + + [ + home_icon_dir, + usr_data_dir, + xdg_data_dirs_local, + xdg_data_dirs, + ] + .into_iter() + .filter(|p| p.exists()) + .collect() +} + +#[derive(Debug)] +pub struct ThemePath(pub PathBuf); + +impl ThemePath { + pub(super) fn index(&self) -> theme::Result { + let index = self.0.join("index.theme"); + + if !index.exists() { + return Err(ThemeError::ThemeIndexNotFound(index)); + } + + Ok(Ini::load_from_file(index)?) + } +} + +#[cfg(test)] +mod test { + use crate::theme::paths::icon_theme_base_paths; + use crate::theme::{get_all_themes, Theme}; + use anyhow::Result; + use speculoos::prelude::*; + + #[test] + fn should_get_theme_paths_ordered() { + let base_paths = icon_theme_base_paths(); + assert_that!(base_paths).is_not_empty() + } + + #[test] + fn should_read_theme_index() -> Result<()> { + let themes = get_all_themes()?; + let themes: Vec<&Theme> = themes.values().collect(); + assert_that!(themes).is_not_empty(); + Ok(()) + } +}