use crate::icon::IconFile;
use crate::{Icons, Theme, ThemeInfo, ThemeParseError};
use states::*;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::marker::PhantomData;
use std::path::PathBuf;
use std::sync::Arc;
macro_rules! states {
($($(#[$($attr:tt)*])* $id:ident),*) => {
mod sealed {
pub trait Sealed {}
}
pub trait TypeStateProtector: sealed::Sealed {}
$(
$(#[$($attr)*])*
pub struct $id;
impl sealed::Sealed for $id {}
impl TypeStateProtector for $id {}
)*
};
}
pub mod states {
states!(
Initial,
LocationsFound,
Finished
);
}
pub struct IconSearch<State = Initial> {
pub dirs: Vec<PathBuf>,
icon_locations: Option<IconLocations>,
icons: Option<Icons>,
_state: PhantomData<fn() -> State>,
}
impl IconSearch<Initial> {
pub fn new() -> Self {
<Self as Default>::default()
}
pub const fn new_empty() -> Self {
Self::new_from(Vec::new())
}
pub const fn new_from(dirs: Vec<PathBuf>) -> Self {
Self {
dirs,
icon_locations: None,
icons: None,
_state: PhantomData,
}
}
pub fn add_directories<I, P>(mut self, directories: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
let mut extra_dirs = directories.into_iter().map(Into::into).collect();
self.dirs.append(&mut extra_dirs);
self
}
fn find_icon_locations(&self) -> IconLocations {
let (dirs, files) = self
.dirs
.iter()
.flat_map(|base_dir| base_dir.read_dir()) .flatten() .flatten() .filter_map(|entry| Some((entry.file_type().ok()?, entry))) .partition::<Vec<_>, _>(|(ft, entry)| {
ft.is_dir() || (entry.path().extension().is_none() && ft.is_symlink())
});
let files = files
.into_iter()
.flat_map(|(_, entry)| IconFile::from_path(&entry.path()))
.collect::<Vec<_>>();
let mut themes_directories: HashMap<OsString, Vec<PathBuf>> = HashMap::new();
for (_, dir) in dirs {
let theme_name = dir.file_name();
themes_directories
.entry(theme_name)
.or_default()
.push(dir.path());
}
IconLocations {
standalone_icons: files,
themes_directories,
}
}
pub fn search(self) -> IconSearch<LocationsFound> {
let icon_locations = self.find_icon_locations();
IconSearch::<LocationsFound> {
dirs: self.dirs,
icon_locations: Some(icon_locations),
icons: None,
_state: PhantomData,
}
}
}
impl IconSearch<LocationsFound> {
pub fn icon_locations(&self) -> &IconLocations {
self.icon_locations
.as_ref()
.expect("guaranteed by type-state")
}
pub fn into_icon_locations(self) -> IconLocations {
self.icon_locations.expect("guaranteed by type-state")
}
fn finish(self) -> IconSearch<Finished> {
let icons = self.icon_locations.expect("guaranteed by type-state");
let icons = icons.icons();
IconSearch {
dirs: self.dirs,
icon_locations: None, icons: Some(icons),
_state: PhantomData,
}
}
pub fn icons(self) -> Icons {
self.finish().icons()
}
#[cfg(feature = "cache")]
#[cfg_attr(docsrs, doc(cfg(feature = "cache")))]
pub fn icons_cached(self) -> crate::cache::IconsCache {
self.finish().icons_cached()
}
}
impl IconSearch<Finished> {
pub fn icons(self) -> Icons {
self.icons.expect("guaranteed by type-state")
}
#[cfg(feature = "cache")]
#[cfg_attr(docsrs, doc(cfg(feature = "cache")))]
pub fn icons_cached(self) -> crate::cache::IconsCache {
self.icons.expect("guaranteed by type-state").into()
}
}
#[derive(Debug)]
pub struct IconLocations {
pub standalone_icons: Vec<IconFile>,
pub themes_directories: HashMap<OsString, Vec<PathBuf>>,
}
impl IconLocations {
pub fn from_icon_search(dirs: &IconSearch<Initial>) -> Self {
dirs.find_icon_locations()
}
pub fn icons(self) -> Icons {
let themes = self.resolve();
let standalone_icons = self
.standalone_icons
.into_iter()
.map(|file| {
let key = file
.path()
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or(String::new());
(key, file)
})
.collect();
Icons {
standalone_icons,
themes,
}
}
#[cfg(feature = "cache")]
#[cfg_attr(docsrs, doc(cfg(feature = "cache")))]
pub fn icons_cached(self) -> crate::cache::IconsCache {
let icons = self.icons();
icons.into()
}
pub fn resolve(&self) -> HashMap<OsString, Arc<Theme>> {
self.resolve_only(self.themes_directories.keys())
}
pub fn resolve_only<I, S>(&self, theme_names: I) -> HashMap<OsString, Arc<Theme>>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
fn collect_themes(
name: &OsStr,
locations: &IconLocations,
themes: &mut HashMap<OsString, Option<ThemeInfo>>,
) {
if themes.contains_key(name) {
return;
}
#[allow(clippy::manual_ok_err)] let info = match locations.load_single_theme(name) {
Ok(d) => Some(d),
Err(_e) => {
#[cfg(feature = "log")]
log::debug!("skipping theme candidate {name:?} because {_e}");
None
}
};
let info = themes.entry(name.to_os_string()).insert_entry(info);
let Some(info) = info.get() else {
return;
};
let parents = info.index.inherits.clone();
for parent in parents {
collect_themes(parent.as_ref(), locations, themes);
}
}
let mut themes = HashMap::new();
for theme_name in theme_names {
let theme_name = theme_name.as_ref();
collect_themes(theme_name, self, &mut themes);
}
collect_themes("hicolor".as_ref(), self, &mut themes);
let (theme_names, mut theme_info): (Vec<_>, Vec<_>) = themes
.into_iter()
.flat_map(|(key, value)| value.map(|v| (key, Some(v))))
.unzip();
debug_assert!(theme_info.iter().all(Option::is_some));
let hicolor_idx = theme_names.iter().position(|name| name == "hicolor");
let number_of_themes = theme_names.len();
let mut theme_chains = Vec::<Vec<usize>>::with_capacity(number_of_themes);
for theme_idx in 0..number_of_themes {
let mut chain = Vec::from([theme_idx]);
let mut cursor = 0;
while let Some(node_idx) = chain.get(cursor).copied() {
cursor += 1;
let Some(Some(info)) = theme_info.get(node_idx) else {
continue;
};
for parent in &info.index.inherits {
let Some(parent_idx) = theme_names
.iter()
.position(|name| *name.as_os_str() == **parent)
else {
continue;
};
chain.retain(|idx| *idx != parent_idx);
chain.push(parent_idx);
}
}
if let Some(hicolor_idx) = hicolor_idx {
chain.retain(|idx| *idx != hicolor_idx);
chain.push(hicolor_idx);
}
theme_chains.push(chain);
}
let mut full_themes = vec![None::<Arc<Theme>>; number_of_themes];
for chain in &theme_chains {
for theme_idx in chain.iter().copied().rev() {
let theme_info = theme_info[theme_idx].take();
let Some(theme_info) = theme_info else {
continue;
};
let parents = &theme_chains[theme_idx];
let parents = parents
.iter()
.skip(1) .copied()
.map(|parent_idx| Arc::clone(full_themes[parent_idx].as_ref().unwrap()))
.collect();
let theme = Theme {
info: theme_info,
inherits_from: parents,
};
full_themes[theme_idx] = Some(Arc::new(theme));
}
}
debug_assert!(full_themes.iter().all(Option::is_some));
let full_themes: Vec<_> = full_themes.into_iter().map(Option::unwrap).collect();
theme_names
.into_iter()
.zip(full_themes)
.collect::<HashMap<_, _>>()
}
pub fn load_single_theme<S>(&self, internal_name: S) -> std::io::Result<ThemeInfo>
where
S: AsRef<OsStr>,
{
let internal_name = internal_name.as_ref();
let theme = self
.themes_directories
.get(internal_name)
.ok_or_else(|| std::io::Error::other(ThemeParseError::NotAnIconTheme))?;
ThemeInfo::new_from_folders(internal_name.to_owned(), theme.clone())
}
pub fn standalone_icon<S>(&self, icon_name: S) -> Option<&IconFile>
where
S: AsRef<OsStr>,
{
let name = icon_name.as_ref();
self.standalone_icons
.iter()
.find(|icon| icon.path().file_stem() == Some(name))
}
}
impl<I, P> From<I> for IconSearch
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
fn from(value: I) -> Self {
let dirs = value.into_iter().map(Into::into).collect();
IconSearch::new_from(dirs)
}
}
impl Default for IconSearch {
fn default() -> Self {
let xdg = xdg::BaseDirectories::new();
let mut directories = vec![];
if let Some(home) = std::env::home_dir() {
directories.push(home.join(".icons"));
}
xdg.data_home
.into_iter()
.chain(xdg.data_dirs)
.map(|data_dir| data_dir.join("icons"))
.for_each(|dir| directories.push(dir));
directories.push("/usr/share/pixmaps".into());
directories.into()
}
}
#[cfg(test)]
pub(crate) mod test {
use crate::search::IconSearch;
use std::collections::HashSet;
use std::path::PathBuf;
static PROJ_ROOT: &'static str = env!("CARGO_MANIFEST_DIR");
pub fn test_search() -> IconSearch {
IconSearch::new_empty().add_directories([
PathBuf::from(PROJ_ROOT).join("resources/test_icons"),
PathBuf::from(PROJ_ROOT).join("resources/test_icons_alt"),
])
}
#[test]
fn test_standard_usage() {
let icons = test_search()
.add_directories(["/this/path/probably/doesnt/exist/but/who/cares/"])
.search();
assert!(
icons
.dirs
.contains(&"/this/path/probably/doesnt/exist/but/who/cares/".into()),
"IconSearch contains the added directory"
);
assert_eq!(icons.icon_locations().themes_directories.len(), 2);
let _ = icons.icons();
}
#[test]
fn test_find_test_theme() {
let dirs = test_search();
let locations = dirs.find_icon_locations();
let theme = locations.load_single_theme("TestTheme").unwrap();
assert_eq!(theme.internal_name, "TestTheme", "name is correct");
assert_eq!(
theme.index.comment,
"This is a theme to test icon's capabilities."
);
assert_eq!(theme.index.hidden, true);
assert_eq!(
theme.base_dirs.len(),
2,
"correct amount of base directories"
);
assert_eq!(theme.index_location.file_name().unwrap(), "index.theme");
assert_eq!(theme.index.name, "HelloTestTheme!");
assert_eq!(
theme.index.comment,
"This is a theme to test icon's capabilities."
);
assert_eq!(theme.index.inherits, vec![String::from("OtherTheme")]);
let directories = theme.index.directories.iter();
assert_eq!(
directories
.clone()
.map(|di| di.directory_name.as_str())
.collect::<HashSet<_>>(),
[
"16x16/α",
"16x16/β",
"32x32/foo",
"UnconventionalDirectoryName/γ",
"128x128"
]
.into()
);
assert_eq!(
directories
.clone()
.map(|di| di.size)
.collect::<HashSet<_>>(),
[16, 32, 64, 128].into()
);
let other_theme = locations.load_single_theme("OtherTheme").unwrap();
assert_eq!(other_theme.internal_name, "OtherTheme");
}
}