use std::{ cmp::Ordering, collections::{HashMap, HashSet, VecDeque}, fs, path::{Component, Path, PathBuf}, }; const FALLBACK_THEMES: &[&str] = &["Cosmic", "hicolor", "gnome", "Yaru"]; const MAX_THEME_CHAIN: usize = 24; const MAX_SCAN_DEPTH: usize = 5; const MAX_CATALOG_ENTRIES: usize = 2_500; #[derive(Debug, Clone)] pub struct IconCatalog { pub theme: String, pub entries: Vec, pub truncated: bool, } #[derive(Debug, Clone)] pub struct IconCatalogEntry { pub name: String, } #[derive(Debug, Clone, Eq, PartialEq)] struct CandidateRank { preferred: u8, category: u8, symbolic: u8, theme_depth: usize, extension: u8, } impl Ord for CandidateRank { fn cmp(&self, other: &Self) -> Ordering { ( self.preferred, self.category, self.symbolic, self.theme_depth, self.extension, ) .cmp(&( other.preferred, other.category, other.symbolic, other.theme_depth, other.extension, )) } } impl PartialOrd for CandidateRank { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } #[derive(Debug, Clone)] struct Candidate { name: String, rank: CandidateRank, } pub async fn load_icon_catalog(theme: String, preferred_icon: String) -> IconCatalog { build_icon_catalog(theme, preferred_icon) } fn build_icon_catalog(theme: String, preferred_icon: String) -> IconCatalog { let theme = if theme.trim().is_empty() { "Cosmic".to_string() } else { theme }; let preferred_name = icon_name_from_value(preferred_icon.trim()); let theme_chain = theme_chain(&theme); let mut candidates = HashMap::new(); for (theme_depth, theme_name) in theme_chain.iter().enumerate() { for theme_dir in theme_dirs(theme_name) { scan_icon_tree( &theme_dir, theme_depth, preferred_name.as_deref(), &mut candidates, ); } } for pixmap_dir in pixmap_dirs() { scan_icon_tree( &pixmap_dir, theme_chain.len() + 1, preferred_name.as_deref(), &mut candidates, ); } let mut entries = candidates.into_values().collect::>(); entries.sort_by(|a, b| a.rank.cmp(&b.rank).then_with(|| a.name.cmp(&b.name))); let truncated = entries.len() > MAX_CATALOG_ENTRIES; entries.truncate(MAX_CATALOG_ENTRIES); IconCatalog { theme, entries: entries .into_iter() .map(|candidate| IconCatalogEntry { name: candidate.name, }) .collect(), truncated, } } fn theme_chain(theme: &str) -> Vec { let mut queue = VecDeque::from([theme.to_string()]); let mut seen = HashSet::new(); let mut chain = Vec::new(); while let Some(theme_name) = queue.pop_front() { let key = theme_name.to_ascii_lowercase(); if !seen.insert(key) { continue; } chain.push(theme_name.clone()); if chain.len() >= MAX_THEME_CHAIN { break; } for parent in read_theme_inherits(&theme_name) { queue.push_back(parent); } } for fallback in FALLBACK_THEMES { if chain.len() >= MAX_THEME_CHAIN { break; } let key = fallback.to_ascii_lowercase(); if seen.insert(key) { chain.push((*fallback).to_string()); } } chain } fn read_theme_inherits(theme: &str) -> Vec { theme_dirs(theme) .into_iter() .find_map(|dir| fs::read_to_string(dir.join("index.theme")).ok()) .map(|contents| parse_inherits(&contents)) .unwrap_or_default() } fn parse_inherits(contents: &str) -> Vec { let mut in_icon_theme = false; for raw_line in contents.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if line.starts_with('[') && line.ends_with(']') { in_icon_theme = line == "[Icon Theme]"; continue; } if in_icon_theme && let Some(value) = line.strip_prefix("Inherits=") { return value .split(',') .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect(); } } Vec::new() } fn scan_icon_tree( root: &Path, theme_depth: usize, preferred_name: Option<&str>, candidates: &mut HashMap, ) { let mut stack = vec![(root.to_path_buf(), 0usize)]; while let Some((dir, depth)) = stack.pop() { if depth > MAX_SCAN_DEPTH { continue; } let Ok(entries) = fs::read_dir(&dir) else { continue; }; for entry in entries.filter_map(Result::ok) { let path = entry.path(); let Ok(file_type) = entry.file_type() else { continue; }; if file_type.is_dir() { stack.push((path, depth + 1)); } else if (file_type.is_file() || file_type.is_symlink()) && let Some(candidate) = candidate_from_path(&path, theme_depth, preferred_name) { insert_candidate(candidates, candidate); } } } } fn insert_candidate(candidates: &mut HashMap, candidate: Candidate) { let key = candidate.name.to_ascii_lowercase(); match candidates.get_mut(&key) { Some(existing) if candidate.rank < existing.rank => { *existing = candidate; } None => { candidates.insert(key, candidate); } _ => {} } } fn candidate_from_path( path: &Path, theme_depth: usize, preferred_name: Option<&str>, ) -> Option { let extension = extension_rank(path)?; let name = path.file_stem()?.to_str()?.trim(); if name.is_empty() { return None; } let symbolic = u8::from(name.ends_with("-symbolic") || path_has_component(path, "symbolic")); let preferred = u8::from(preferred_name != Some(name)); Some(Candidate { name: name.to_string(), rank: CandidateRank { preferred, category: category_rank(path), symbolic, theme_depth, extension, }, }) } fn extension_rank(path: &Path) -> Option { match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { "svg" => Some(0), "png" => Some(1), "xpm" => Some(2), _ => None, } } fn category_rank(path: &Path) -> u8 { for component in path.components().filter_map(component_str) { match component { "apps" | "applications" => return 0, "categories" => return 1, "places" => return 2, "devices" => return 3, "mimetypes" => return 4, "actions" => return 5, "status" => return 6, _ => {} } } 7 } fn path_has_component(path: &Path, needle: &str) -> bool { path.components() .filter_map(component_str) .any(|component| component == needle) } fn component_str(component: Component<'_>) -> Option<&str> { component.as_os_str().to_str() } fn icon_name_from_value(value: &str) -> Option { if value.is_empty() { return None; } let path = Path::new(value); if value.contains('/') { return path .file_stem() .and_then(|name| name.to_str()) .map(ToOwned::to_owned); } path.file_stem() .and_then(|name| name.to_str()) .map(ToOwned::to_owned) .or_else(|| Some(value.to_string())) } fn theme_dirs(theme: &str) -> Vec { icon_base_dirs() .into_iter() .map(|base| base.join(theme)) .filter(|path| path.is_dir()) .collect() } fn icon_base_dirs() -> Vec { let mut dirs = Vec::new(); if let Some(home) = std::env::home_dir() { push_existing_unique(&mut dirs, home.join(".icons")); } if let Some(data_home) = xdg_data_home() { push_existing_unique(&mut dirs, data_home.join("icons")); } for data_dir in xdg_data_dirs() { push_existing_unique(&mut dirs, data_dir.join("icons")); } dirs } fn pixmap_dirs() -> Vec { let mut dirs = Vec::new(); if let Some(data_home) = xdg_data_home() { push_existing_unique(&mut dirs, data_home.join("pixmaps")); } for data_dir in xdg_data_dirs() { push_existing_unique(&mut dirs, data_dir.join("pixmaps")); } push_existing_unique(&mut dirs, PathBuf::from("/usr/share/pixmaps")); dirs } fn xdg_data_home() -> Option { std::env::var_os("XDG_DATA_HOME") .filter(|value| !value.is_empty()) .map(PathBuf::from) .or_else(|| std::env::home_dir().map(|home| home.join(".local/share"))) } fn xdg_data_dirs() -> Vec { std::env::var_os("XDG_DATA_DIRS") .filter(|value| !value.is_empty()) .map(|value| std::env::split_paths(&value).collect()) .unwrap_or_else(|| { vec![ PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share"), ] }) } fn push_existing_unique(dirs: &mut Vec, path: PathBuf) { if path.exists() && !dirs.iter().any(|existing| existing == &path) { dirs.push(path); } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_inherits_only_from_icon_theme_section() { let contents = r#" [Other] Inherits=Wrong [Icon Theme] Name=Demo Inherits=Cosmic, hicolor , Adwaita "#; assert_eq!(parse_inherits(contents), ["Cosmic", "hicolor", "Adwaita"]); } #[test] fn normalizes_icon_names_from_plain_names_and_paths() { assert_eq!(icon_name_from_value("firefox").as_deref(), Some("firefox")); assert_eq!( icon_name_from_value("/usr/share/icons/hicolor/scalable/apps/firefox.svg").as_deref(), Some("firefox") ); } #[test] fn keeps_the_best_duplicate_candidate() { let mut candidates = HashMap::new(); insert_candidate( &mut candidates, Candidate { name: "demo".to_string(), rank: CandidateRank { preferred: 1, category: 7, symbolic: 1, theme_depth: 4, extension: 2, }, }, ); insert_candidate( &mut candidates, Candidate { name: "demo".to_string(), rank: CandidateRank { preferred: 0, category: 0, symbolic: 0, theme_depth: 0, extension: 0, }, }, ); assert_eq!(candidates["demo"].rank.preferred, 0); assert_eq!(candidates["demo"].rank.category, 0); } }