diff --git a/resources/example.index.theme b/resources/example.index.theme new file mode 100644 index 0000000..f252d86 --- /dev/null +++ b/resources/example.index.theme @@ -0,0 +1,48 @@ +[Icon Theme] +Name=Birch +Name[sv]=Björk +Comment=Icon theme with a wooden look +Comment[sv]=Träinspirerat ikontema +Inherits=wood,default +Directories=48x48/apps,48x48@2/apps,48x48/mimetypes,32x32/apps,32x32@2/apps,scalable/apps,scalable/mimetypes + +[scalable/apps] +Size=48 +Type=Scalable +MinSize=1 +MaxSize=256 +Context=Applications + +[scalable/mimetypes] +Size=48 +Type=Scalable +MinSize=1 +MaxSize=256 +Context=MimeTypes + +[32x32/apps] +Size=32 +Type=Fixed +Context=Applications + +[32x32@2/apps] +Size=32 +Scale=2 +Type=Fixed +Context=Applications + +[48x48/apps] +Size=48 +Type=Fixed +Context=Applications + +[48x48@2/apps] +Size=48 +Scale=2 +Type=Fixed +Context=Applications + +[48x48/mimetypes] +Size=48 +Type=Fixed +Context=MimeTypes \ No newline at end of file diff --git a/src/search_dir.rs b/src/search_dir.rs index ee8d709..f72716c 100644 --- a/src/search_dir.rs +++ b/src/search_dir.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::icon::IconFile; @@ -11,7 +12,7 @@ impl SearchDirectories { ::default() } - pub fn search_icons_and_theme_indexes(&self) -> (Vec, Vec) { + pub fn search_icons_and_theme_folders(&self) -> (Vec, HashMap>) { fn theme_name_from_path(path: &Path) -> Option<&str> { let theme_name = path.components() .nth_back(1); // get the second-to-last component (which should be the theme name) @@ -21,31 +22,33 @@ impl SearchDirectories { // "Each theme is stored as subdirectories of the base directories" - let (dirs, files) = self.dirs.iter() + let (files, dirs) = self.dirs.iter() .flat_map(|base_dir| base_dir.read_dir()) // read the entries in each base dir .flat_map(std::convert::identity) // merge all the iterators .flat_map(std::convert::identity) // remove Err entries .filter_map(|entry| Some((entry.file_type().ok()?, entry))) // get file type for each entry and skip if fail - .partition::, _>(|(ft, _)| ft.is_dir()); + .partition::, _>(|(ft, _)| ft.is_file()); // icons at the top-level in a base_dir don't belong to a theme, but must still be able to be found! let files = files.into_iter() .flat_map(|(_, entry)| IconFile::from_path(&entry.path())) .collect::>(); - + // "In at least one of the theme directories there must be a file called // index.theme that describes the theme. The first index.theme found while // searching the base directories in order is used" - let mut indexes = dirs.into_iter() - .map(|(_, entry)| entry.path().join("index.theme")) - .filter(|path| path.exists()) // the index.theme file must exist - .collect::>(); + // For each theme name, create a list of directories where it may be found: + let mut theme_folders: HashMap> = HashMap::new(); + for (_, dir) in dirs { + let theme_name = dir.file_name().to_string_lossy().into_owned(); - // only keep the first `index.theme` for each theme - indexes.dedup_by_key(|path| theme_name_from_path(&path).map(|s| s.to_string())); - - (files, indexes) + theme_folders.entry(theme_name) + .or_insert(Default::default()) + .push(dir.path()); + } + + (files, theme_folders) } } @@ -96,7 +99,7 @@ mod test { fn test_find_htop_icon() { let dirs = SearchDirectories::default(); - let (icons, _indexes) = dirs.search_icons_and_theme_indexes(); + let (icons, _indexes) = dirs.search_icons_and_theme_folders(); assert!(icons.iter().any(|i| i.path.file_name().and_then(|s| s.to_str()) == Some("htop.png"))) } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..91d071e --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,339 @@ +use crate::theme::ThemeParseError::MissingRequiredAttribute; +use freedesktop_entry_parser::low_level::{EntryIter, SectionBytes}; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +pub type OwnedTheme = Theme<'static>; +pub type OwnedThemeIndex = ThemeIndex<'static>; +pub type OwnedDirectoryIndex = DirectoryIndex<'static>; + +pub struct Theme<'a> { + pub internal_name: String, + pub base_dirs: Vec>, + pub index_location: PathBuf, + pub index: ThemeIndex<'a>, + // additional groups? +} + +#[derive(Debug, thiserror::Error)] +pub enum ThemeParseError { + #[error("missing Icon Theme index or section")] + NotAnIconTheme, + #[error("missing attribute `{0}`")] + MissingRequiredAttribute(&'static str), + #[error("the input wasn't in utf-8")] + NotUtf8(#[from] std::str::Utf8Error), + #[error("a bool was expected but failed to parse")] + ParseBoolError(#[from] std::str::ParseBoolError), + #[error("a number was expected but failed to parse")] + ParseNumError(#[from] std::num::ParseIntError), + #[error("A directory type was invalid")] + InvalidDirectoryType, + #[error("invalid format for a freedesktop entry file")] + ParseError(#[from] freedesktop_entry_parser::ParseError), +} + +impl Theme<'_> { + pub fn new_from_folders(internal_name: String, folders: Vec) -> std::io::Result { + let index_location = folders + .iter() + .map(|f| f.join("index.theme")) + .filter(|index_path| index_path.exists()) + .next() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, ThemeParseError::NotAnIconTheme) + })?; + + let index = ThemeIndex::parse_from_file(index_location.as_path())?; + + Ok(Self { + internal_name, + base_dirs: folders.into_iter().map(Into::into).collect(), + index_location, + index, + }) + } +} + +fn theme_into_owned(theme: Theme) -> OwnedTheme { + let base_dirs = theme + .base_dirs + .into_iter() + .map(Cow::into_owned) + .map(Into::into) + .collect(); + let index = theme.index.into_owned(); + + OwnedTheme { + base_dirs, + index, + ..theme + } +} + +pub struct ThemeIndex<'a> { + pub name: Cow<'a, str>, + pub comment: Cow<'a, str>, + pub inherits: Vec>, + pub directories: Vec>, + pub hidden: bool, + pub example: Option>, +} + +impl<'a> ThemeIndex<'a> { + pub fn parse_from_file(path: &Path) -> std::io::Result { + let bytes = std::fs::read(path)?; + let index = ThemeIndex::parse(&bytes) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(index.into_owned()) + } + + pub fn parse(bytes: &'a [u8]) -> Result { + let mut entry: EntryIter<'a> = freedesktop_entry_parser::low_level::parse_entry(bytes); + + let icon_theme_section: SectionBytes<'a> = + entry.next().ok_or(ThemeParseError::NotAnIconTheme)??; + let name: &'a str = find_attr_req(&icon_theme_section, "Name")?; + let comment = find_attr_req(&icon_theme_section, "Comment")?; + // 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")? + .unwrap_or("hicolor") + .split(',') // `inherits` is a comma-separated string list + .map(Into::into) + .collect::>(); + let directories = find_attr_req(&icon_theme_section, "Directories")? + .split(',') + .collect::>(); + let scaled_directories = find_attr(&icon_theme_section, "ScaledDirectories")? + .map(|s| s.split(',').collect::>()); + 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 { + // section isn't a listed directory! ignore! + return None; + } + + let mut index = DirectoryIndex::parse(section); + + if is_scaled_dir { + if let Ok(index) = &mut index { + index.is_scaled_dir = true; + } + } + + Some(index) + }) + .collect::, ThemeParseError>>()?; + + Ok(Self { + name: name.into(), + comment: comment.into(), + inherits, + directories, + hidden, + example: example.map(Into::into), + }) + } + + pub fn into_owned(self) -> OwnedThemeIndex { + theme_index_into_owned(self) + } +} + +fn theme_index_into_owned(index: ThemeIndex) -> OwnedThemeIndex { + let name = index.name.into_owned().into(); + let comment = index.comment.into_owned().into(); + let inherits = index + .inherits + .into_iter() + .map(Cow::into_owned) + .map(Into::into) + .collect(); + let directories = index + .directories + .into_iter() + .map(|x| x.into_owned()) + .collect(); + let example = index.example.map(Cow::into_owned).map(Into::into); + + OwnedThemeIndex { + name, + comment, + inherits, + directories, + example, + ..index + } +} + +pub struct DirectoryIndex<'a> { + pub directory_name: Cow<'a, str>, + pub is_scaled_dir: bool, + pub size: u32, + pub scale: u32, + pub context: Option>, + pub directory_type: DirectoryType, + pub max_size: u32, + pub min_size: u32, + pub threshold: u32, + // pub additional_values: HashMap, Cow<'a, str>>, +} + +impl<'a> DirectoryIndex<'a> { + fn parse(section: SectionBytes<'a>) -> Result { + let dir_name = str::from_utf8(section.title)?; + let size: u32 = find_attr_req(§ion, "Size")?.parse()?; + let scale: u32 = find_attr(§ion, "Scale")? + .map(|s| s.parse()) + .transpose()? + .unwrap_or(1); + let context = find_attr(§ion, "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(§ion, "Type")? + .map(|s| s.try_into()) + .transpose() + .map_err(|_| ThemeParseError::InvalidDirectoryType)? + .unwrap_or(DirectoryType::Threshold); + let max_size = find_attr(§ion, "MaxSize")? + .map(|s| s.parse()) + .transpose()? + .unwrap_or(size); + let min_size = find_attr(§ion, "MinSize")? + .map(|s| s.parse()) + .transpose()? + .unwrap_or(size); + let threshold = find_attr(§ion, "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 fn into_owned(self) -> OwnedDirectoryIndex { + dir_index_into_owned(self) + } +} + +fn dir_index_into_owned(index: DirectoryIndex) -> OwnedDirectoryIndex { + let directory_name: Cow<'static, str> = index.directory_name.into_owned().into(); + let context: Option> = index.context.map(|c| c.into_owned().into()); + // let additional_values: HashMap, Cow<'static, str>> = index + // .additional_values + // .into_iter() + // .map(|(k, v)| (Cow::Owned(k.into_owned()), Cow::Owned(v.into_owned()))) + // .collect(); + + OwnedDirectoryIndex { + directory_name, + context, + // additional_values, + ..index + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum DirectoryType { + Fixed, + Scalable, + Threshold, +} + +impl TryFrom<&str> for DirectoryType { + type Error = (); + + fn try_from(value: &str) -> Result { + let value = match value { + "Fixed" => DirectoryType::Fixed, + "Scalable" => DirectoryType::Scalable, + "Threshold" => DirectoryType::Threshold, + _ => return Err(()), + }; + + Ok(value) + } +} + +fn find_attr<'a>( + section: &SectionBytes<'a>, + name: &str, +) -> Result, 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: &SectionBytes<'a>, + name: &'static str, +) -> Result<&'a str, ThemeParseError> { + find_attr(section, name)?.ok_or(MissingRequiredAttribute(name)) +} + +#[cfg(test)] +mod test { + use crate::theme::{DirectoryType, ThemeIndex}; + use std::error::Error; + + #[test] + fn test_parse_example_theme() -> Result<(), Box> { + 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(()) + } +}