mirror of
https://github.com/danbulant/icon
synced 2026-05-19 04:08:36 +00:00
403 lines
14 KiB
Rust
403 lines
14 KiB
Rust
use crate::icon::IconFile;
|
|
use crate::theme::{Icons, Theme, ThemeInfo, ThemeParseError};
|
|
use std::collections::HashMap;
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
/// Icons and icon themes are looked for in a set of directories.
|
|
///
|
|
/// By default, that is `$HOME/.icons`, `$XDG_DATA_DIRS/icons` and `/usr/share/pixmaps`.
|
|
/// Applications may further add their own icon directories to this list, and users may extend or change the list.
|
|
/// The default list may be obtained using the `Default` implementation on `IconSearch` or its `default` method.
|
|
///
|
|
/// To add directories to the instance, use [IconSearch::add_directories].
|
|
///
|
|
/// To construct a new `IconSearch` from a list, use the `From` implementation or construct it by hand.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use icon::IconSearch;
|
|
///
|
|
/// let dirs = IconSearch::default();
|
|
/// // TODO
|
|
/// ```
|
|
#[derive(Debug, Clone)]
|
|
pub struct IconSearch {
|
|
pub dirs: Vec<PathBuf>,
|
|
}
|
|
|
|
impl IconSearch {
|
|
pub const fn new_empty() -> Self {
|
|
Self {
|
|
dirs: Vec::new()
|
|
}
|
|
}
|
|
|
|
pub fn default() -> Self {
|
|
<Self as Default>::default()
|
|
}
|
|
|
|
/// Add a list of directories to this `IconSearch`
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use icon::IconSearch;
|
|
///
|
|
/// let dirs = IconSearch::default().add_directories(["/home/root/.icons"]);
|
|
/// ```
|
|
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);
|
|
|
|
extra_dirs.into()
|
|
}
|
|
|
|
pub fn find_icon_locations(&self) -> IconLocations {
|
|
// "Each theme is stored as subdirectories of the base directories"
|
|
|
|
let (files, dirs) = self
|
|
.dirs
|
|
.iter()
|
|
.flat_map(|base_dir| base_dir.read_dir()) // read the entries in each base dir
|
|
.flatten() // merge all the iterators
|
|
.flatten() // remove Err entries
|
|
.filter_map(|entry| Some((entry.file_type().ok()?, entry))) // get file type for each entry and skip if fail
|
|
.partition::<Vec<_>, _>(|(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::<Vec<_>>();
|
|
|
|
// "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"
|
|
|
|
// For each theme name, create a list of directories where it may be found:
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct IconLocations {
|
|
pub standalone_icons: Vec<IconFile>,
|
|
pub themes_directories: HashMap<OsString, Vec<PathBuf>>,
|
|
}
|
|
|
|
impl IconLocations {
|
|
pub fn icons(self) -> Icons {
|
|
let themes = self.resolve();
|
|
|
|
Icons {
|
|
standalone_icons: self.standalone_icons,
|
|
themes,
|
|
}
|
|
}
|
|
|
|
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>,
|
|
{
|
|
// Icon themes may transitively depend on the same icon theme many times.
|
|
// This is a bit of an issue, as when an exhaustive icon lookup would be implemented naively,
|
|
// users may end up searching the same icon theme multiple times.
|
|
// To accommodate this, either one has to keep a list of visited icon themes every time they
|
|
// perform a lookup, or avoid the issue altogether by removing redundant parents up-front.
|
|
|
|
// That second option is what this function does, being to pay a (rather small) one-time cost to
|
|
// make the rest of the API cleaner and smaller. It guarantees that the returned icon themes
|
|
// have dependencies that form a direct acyclic graph without redundant paths.
|
|
|
|
fn collect_themes(
|
|
name: &OsStr,
|
|
locations: &IconLocations,
|
|
themes: &mut HashMap<OsString, Option<ThemeInfo>>,
|
|
) {
|
|
// Skip if we already have this theme.
|
|
if themes.contains_key(name) {
|
|
return;
|
|
}
|
|
|
|
let descriptor = match locations.theme_description(name) {
|
|
Ok(d) => Some(d),
|
|
Err(_e) => {
|
|
#[cfg(feature = "log")]
|
|
log::debug!("skipping theme candidate {name:?} because {_e}");
|
|
|
|
None
|
|
}
|
|
};
|
|
let descriptor = themes.entry(name.to_os_string()).insert_entry(descriptor);
|
|
|
|
let Some(descriptor) = descriptor.get() else {
|
|
return;
|
|
};
|
|
|
|
let parents = descriptor.index.inherits.clone();
|
|
|
|
// Collect all parents of this theme:
|
|
for parent in parents {
|
|
collect_themes(parent.as_ref(), locations, themes);
|
|
}
|
|
}
|
|
|
|
// Map from theme names to their descriptor:
|
|
let mut themes = HashMap::new();
|
|
|
|
// collect all required themes:
|
|
for theme_name in theme_names {
|
|
let theme_name = theme_name.as_ref();
|
|
collect_themes(theme_name.as_ref(), &self, &mut themes);
|
|
}
|
|
|
|
// make 100% sure we have `hicolor`, for the half-impossible edge-case of only collecting
|
|
// themes that does not have hicolor in their inheritance tree
|
|
collect_themes("hicolor".as_ref(), &self, &mut themes);
|
|
// of course, the user might be cursed and not have `hicolor` installed at all!
|
|
// that is troubling, but we'll see that it is handled correctly below.
|
|
|
|
// let's prune theme candidates that have no description (meaning they weren't themes, or
|
|
// were invalid)
|
|
// we'll also split them up, as `theme_chains` borrows names from `theme_names`,
|
|
// but we need to mutate theme_descriptions later (during the borrow) to avoid
|
|
// cloning the descriptions
|
|
let (theme_names, mut theme_descriptions): (Vec<_>, Vec<_>) = themes
|
|
.into_iter()
|
|
.flat_map(|(key, value)| value.map(|v| (key, Some(v))))
|
|
.unzip();
|
|
|
|
// the Options are there just so we can take descriptions out of the vec without messing up the order.
|
|
debug_assert!(theme_descriptions.iter().all(Option::is_some));
|
|
|
|
// do we even have hicolor?
|
|
// if not, there's no use in inserting hicolor into the inheritance tree later
|
|
let hicolor_idx = theme_names.iter().position(|name| name == "hicolor");
|
|
|
|
// Time to find the optimal ancestry for each theme.
|
|
// as hicolor _should_ have all icons by default, and all themes depend on hicolor at some depth,
|
|
// DFS would de facto end up in hicolor before ever trying the second theme in an Inherits set.
|
|
// therefore BFS is the only sensible option, but the spec doesn't define this.
|
|
|
|
// indexed by the position in our theme_names/theme_descriptions vecs
|
|
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(description)) = theme_descriptions.get(node_idx) else {
|
|
continue;
|
|
};
|
|
|
|
for parent in &description.index.inherits {
|
|
let Some(parent_idx) = theme_names
|
|
.iter()
|
|
.position(|name| *name.as_os_str() == **parent)
|
|
else {
|
|
// this parent was invalid
|
|
continue;
|
|
};
|
|
|
|
if !chain.contains(&parent_idx) {
|
|
chain.push(parent_idx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// From the spec: "If no theme is specified, implementations are required to add the
|
|
// "hicolor" theme to the inheritance tree."
|
|
if let Some(hicolor_idx) = hicolor_idx {
|
|
if !chain.contains(&hicolor_idx) {
|
|
chain.push(hicolor_idx);
|
|
}
|
|
}
|
|
|
|
theme_chains.push(chain);
|
|
}
|
|
|
|
// at this point `theme_chains` contains a _topological order_ for each theme's parents,
|
|
// meaning we can easily iterate over it, constructing `Theme`s, assuming at every point
|
|
// that each parent already has a `Theme` created for it :)
|
|
|
|
// again indexed by theme indices, None values mean the theme hasn't been processed yet.
|
|
// the goal is that, by the end of the for loop, that this only contains `Some`s.
|
|
// we rely on the topological order of chains to always have all the prerequisite themes
|
|
// present already in this map!
|
|
let mut full_themes = vec![None::<Arc<Theme>>; number_of_themes];
|
|
|
|
for chain in &theme_chains {
|
|
// go from last theme to first, as all dependencies are "forward" in the chain:
|
|
for theme_idx in chain.iter().copied().rev() {
|
|
let theme_desc = theme_descriptions[theme_idx].take();
|
|
|
|
let Some(theme_desc) = theme_desc else {
|
|
// the option was None, meaning this theme was processed already :-)
|
|
continue;
|
|
};
|
|
|
|
let parents = &theme_chains[theme_idx];
|
|
let parents = parents
|
|
.into_iter()
|
|
.skip(1) // the first in the chain is the theme itself, which we'll ignore—it's not a parent.
|
|
.copied()
|
|
// unwrap OK because, by the topological order, all of these parents
|
|
// should already be present in the array:
|
|
.map(|parent_idx| Arc::clone(full_themes[parent_idx].as_ref().unwrap()))
|
|
.collect();
|
|
|
|
let theme = Theme {
|
|
description: theme_desc,
|
|
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();
|
|
|
|
// and so, we have reached the end of the Big Beautiful Function.
|
|
// `full_themes` is a list of
|
|
// - All themes requested,
|
|
// - all themes required by the inheritance tree of those themes, without duplicates,
|
|
// - and an optimal chain (inheritance tree search order) for each theme.
|
|
|
|
// and to wrap things up, let's zip the themes back up with their names
|
|
theme_names
|
|
.into_iter()
|
|
.zip(full_themes)
|
|
.collect::<HashMap<_, _>>()
|
|
}
|
|
|
|
pub fn theme_description<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_string_lossy().into_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))
|
|
}
|
|
}
|
|
|
|
/// Anything that turns into an iterator of things that can become paths, can be turned into a `IconSearch`.
|
|
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 { dirs }
|
|
}
|
|
}
|
|
|
|
impl Default for IconSearch {
|
|
fn default() -> Self {
|
|
// "By default, apps should look in $HOME/.icons (for backwards compatibility),
|
|
// in $XDG_DATA_DIRS/icons
|
|
// and in /usr/share/pixmaps (in that order)."
|
|
|
|
let xdg = xdg::BaseDirectories::new();
|
|
|
|
let mut directories = vec![];
|
|
|
|
if let Some(home) = std::env::home_dir() {
|
|
directories.push(home.join(".icons"));
|
|
}
|
|
|
|
xdg.data_dirs
|
|
.into_iter()
|
|
.map(|data_dir| data_dir.join("icons"))
|
|
.for_each(|dir| directories.push(dir));
|
|
|
|
directories.push("/usr/share/pixmaps".into());
|
|
|
|
directories.into()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use crate::search::IconSearch;
|
|
|
|
// these tests assume certain applications are installed on the system they are ran on.
|
|
|
|
#[test]
|
|
fn test_find_standard_theme_and_icon() {
|
|
let dirs = IconSearch::default();
|
|
|
|
let locations = dirs.find_icon_locations();
|
|
|
|
let descriptor = locations.theme_description("Adwaita").unwrap();
|
|
assert_eq!(descriptor.index.name, "Adwaita");
|
|
|
|
let icon = locations.standalone_icon("htop").unwrap();
|
|
assert_eq!(icon.path.file_name(), Some("htop.png".as_ref()))
|
|
}
|
|
|
|
#[test]
|
|
fn test_2() {
|
|
let result = IconSearch::default()
|
|
.find_icon_locations()
|
|
.theme_description("breeze")
|
|
.unwrap();
|
|
|
|
println!("{:?}", result.index.inherits);
|
|
}
|
|
|
|
#[test]
|
|
fn test() {
|
|
let _dirs = IconSearch::default().find_icon_locations().resolve();
|
|
|
|
// it didn't panic.
|
|
}
|
|
}
|