//! Linux resolver: freedesktop icon-theme lookup (active theme → its //! inherits → hicolor), painting the found SVG through gpui. The lookup is //! pure, so fixtures run on every OS; theme discovery lives behind the Linux cfg. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use crate::IconRole; /// Directory-name ranking when matching the requested size. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] enum SizeScore { Scalable, Exact(u32), Any, } fn size_score(directory: &str, requested: u32) -> SizeScore { if directory == "scalable" { return SizeScore::Scalable; } let digits: String = directory.chars().take_while(char::is_ascii_digit).collect(); match digits.parse::() { Ok(found) => SizeScore::Exact(found.abs_diff(requested)), Err(_) => SizeScore::Any, } } /// Walks the theme's inherits chain (capped) plus `hicolor`. /// /// Preferring the theme's own candidates over the fallback theme's. pub(crate) fn lookup( candidates: &[&str], theme: &str, requested: u32, base_dirs: &[PathBuf], ) -> Option { let mut themes = vec![theme.to_string()]; let mut index = 0; while themes.len() < 5 && index < themes.len() { if let Some(parents) = inherits(&themes[index], base_dirs) { themes.extend(parents); } index += 1; } themes.push("hicolor".to_string()); for theme in &themes { for name in candidates { if let Some(path) = find_named(base_dirs, theme, name, requested) { return Some(path); } } } None } fn inherits(theme: &str, base_dirs: &[PathBuf]) -> Option> { let index = base_dirs .iter() .map(|base| base.join(theme).join("index.theme")) .find(|index| index.is_file())?; let text = std::fs::read_to_string(index).ok()?; let line = text .lines() .find(|line| line.trim().starts_with("Inherits="))?; Some( line.trim() .strip_prefix("Inherits=")? .split(',') .map(str::trim) .filter(|name| !name.is_empty()) .map(str::to_string) .collect(), ) } fn find_named(base_dirs: &[PathBuf], theme: &str, name: &str, requested: u32) -> Option { let mut best: Option<(SizeScore, PathBuf)> = None; for base in base_dirs { search_directory(&base.join(theme), name, requested, 0, &mut best); } best.map(|(_, path)| path) } /// Search directory recursively with bounded depth. fn search_directory( directory: &Path, name: &str, requested: u32, depth: usize, best: &mut Option<(SizeScore, PathBuf)>, ) { if depth > 2 { return; } let Ok(entries) = std::fs::read_dir(directory) else { return; }; let icon = format!("{name}.svg"); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { search_directory(&path, name, requested, depth + 1, best); } else if path.file_name().is_some_and(|file| *file == *icon) { let score = icon_score(&path, requested); if best .as_ref() .is_none_or(|(best_score, _)| score < *best_score) { *best = Some((score, path)); } } } } /// Scores from the icon's parent and grandparent directory names, /// whichever carries the size (`scalable`, `16x16`, `24`, etc.). fn icon_score(path: &Path, requested: u32) -> SizeScore { let parent = path .parent() .and_then(|parent| parent.file_name()) .and_then(|name| name.to_str()); let grandparent = path .parent() .and_then(|parent| parent.parent()) .and_then(|parent| parent.file_name()) .and_then(|name| name.to_str()); [parent, grandparent] .into_iter() .flatten() .map(|name| size_score(name, requested)) .min() .unwrap_or(SizeScore::Any) } /// The discovered icon file for a role at a size, cached for the process. type IconCache = Mutex>>; pub(crate) fn cached_file(role: IconRole, requested: u32) -> Option { static CACHE: OnceLock = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache.lock().expect("icon cache poisoned"); cache .entry((role.slug(), requested)) .or_insert_with(|| { lookup( role.freedesktop_names(), &active_theme(), requested, &base_dirs(), ) }) .clone() } #[cfg(target_os = "linux")] mod discovery { use std::path::PathBuf; fn home_dir() -> Option { std::env::var_os("HOME").map(PathBuf::from) } /// `$HOME/.icons`, `$XDG_DATA_HOME/icons`, `$XDG_DATA_DIRS/icons`. pub(crate) fn base_dirs() -> Vec { let mut dirs = Vec::new(); if let Some(home) = home_dir() { dirs.push(home.join(".icons")); dirs.push(home.join(".local/share/icons")); } let data_dirs = std::env::var("XDG_DATA_DIRS") .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); for data_dir in data_dirs.split(':').filter(|dir| !dir.is_empty()) { dirs.push(PathBuf::from(data_dir).join("icons")); } dirs } fn gsettings_icon_theme() -> Option { let output = std::process::Command::new("gsettings") .args(["get", "org.gnome.desktop.interface", "icon-theme"]) .output() .ok()?; if !output.status.success() { return None; } let value = String::from_utf8(output.stdout).ok()?; Some(value.trim().trim_matches('\'').trim().to_string()) } fn kdeglobals_icon_theme() -> Option { let text = std::fs::read_to_string(home_dir()?.join(".config/kdeglobals")).ok()?; let mut section = None; for line in text.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix('[') { section = Some(rest.split(']').next().unwrap_or(rest).trim().to_string()); } else if section.as_deref() == Some("Icons") && let Some((key, value)) = line.split_once('=') && key.trim() == "Theme" { return Some(value.trim().to_string()); } } None } fn omarchy_icon_theme() -> Option { let path = home_dir()?.join(".local/state/omarchy/current/theme/icons.theme"); let text = std::fs::read_to_string(path).ok()?; Some(text.trim().to_string()) } pub(crate) fn active_theme() -> String { omarchy_icon_theme() .or_else(kdeglobals_icon_theme) .or_else(gsettings_icon_theme) .filter(|theme| !theme.is_empty()) .unwrap_or_else(|| "Adwaita".to_string()) } } #[cfg(target_os = "linux")] use discovery::{active_theme, base_dirs}; #[cfg(not(target_os = "linux"))] fn active_theme() -> String { "Adwaita".to_string() } #[cfg(not(target_os = "linux"))] fn base_dirs() -> Vec { Vec::new() } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("gpui-native-icons-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("scratch dir"); root } fn write(path: &Path, contents: &str) { std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs"); std::fs::write(path, contents).expect("write"); } #[test] fn the_theme_beats_hicolor() { let base = scratch("precedence"); write( &base.join("mytheme/scalable/actions/object-select-symbolic.svg"), "", ); write( &base.join("hicolor/scalable/actions/object-select-symbolic.svg"), "", ); assert_eq!( lookup( &["object-select-symbolic"], "mytheme", 16, std::slice::from_ref(&base) ), Some(base.join("mytheme/scalable/actions/object-select-symbolic.svg")) ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn a_missing_glyph_falls_through_to_hicolor() { let base = scratch("hicolor"); write(&base.join("hicolor/24/actions/search.svg"), ""); assert_eq!( lookup(&["search"], "breeze", 24, std::slice::from_ref(&base)), Some(base.join("hicolor/24/actions/search.svg")) ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn the_inherits_chain_is_walked() { let base = scratch("inherits"); write(&base.join("parent/scalable/actions/add.svg"), ""); write( &base.join("child/index.theme"), "[Icon Theme]\nInherits=parent\n", ); assert_eq!( lookup(&["add"], "child", 16, std::slice::from_ref(&base)), Some(base.join("parent/scalable/actions/add.svg")) ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn scalable_wins_and_the_closest_size_wins_without_it() { let base = scratch("sizes"); write(&base.join("theme/16x16/actions/check.svg"), ""); write(&base.join("theme/48x48/actions/check.svg"), ""); write(&base.join("theme/scalable/actions/check.svg"), ""); assert_eq!( lookup(&["check"], "theme", 16, std::slice::from_ref(&base)), Some(base.join("theme/scalable/actions/check.svg")) ); let base = scratch("sizes-plain"); write(&base.join("theme/16x16/actions/check.svg"), ""); write(&base.join("theme/48x48/actions/check.svg"), ""); assert_eq!( lookup(&["check"], "theme", 16, std::slice::from_ref(&base)), Some(base.join("theme/16x16/actions/check.svg")) ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn both_theme_layouts_are_searched() { let adwaita = scratch("layout-adwaita"); write(&adwaita.join("theme/scalable/actions/check.svg"), ""); assert_eq!( lookup(&["check"], "theme", 16, std::slice::from_ref(&adwaita)), Some(adwaita.join("theme/scalable/actions/check.svg")) ); let breeze = scratch("layout-breeze"); write(&breeze.join("theme/actions/16/check.svg"), ""); assert_eq!( lookup(&["check"], "theme", 16, std::slice::from_ref(&breeze)), Some(breeze.join("theme/actions/16/check.svg")) ); std::fs::remove_dir_all(&adwaita).expect("cleanup"); std::fs::remove_dir_all(&breeze).expect("cleanup"); } #[test] fn png_only_themes_are_skipped() { let base = scratch("png"); write(&base.join("theme/16x16/actions/check.png"), "png"); assert_eq!( lookup(&["check"], "theme", 16, std::slice::from_ref(&base)), None ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn a_themes_second_candidate_beats_hicolors_first() { let base = scratch("candidates"); write( &base.join("mytheme/scalable/actions/emblem-ok-symbolic.svg"), "", ); write( &base.join("hicolor/scalable/actions/object-select-symbolic.svg"), "", ); assert_eq!( lookup( &["object-select-symbolic", "emblem-ok-symbolic"], "mytheme", 16, std::slice::from_ref(&base) ), Some(base.join("mytheme/scalable/actions/emblem-ok-symbolic.svg")) ); std::fs::remove_dir_all(&base).expect("cleanup"); } #[test] fn every_role_states_freedesktop_candidates() { for role in crate::IconRole::all() { assert!(!role.freedesktop_names().is_empty()); assert!(!role.slug().is_empty()); } } }