404 lines
12 KiB
Rust
404 lines
12 KiB
Rust
//! 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::<u32>() {
|
|
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<PathBuf> {
|
|
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<Vec<String>> {
|
|
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<PathBuf> {
|
|
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<HashMap<(&'static str, u32), Option<PathBuf>>>;
|
|
|
|
pub(crate) fn cached_file(role: IconRole, requested: u32) -> Option<PathBuf> {
|
|
static CACHE: OnceLock<IconCache> = 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<PathBuf> {
|
|
std::env::var_os("HOME").map(PathBuf::from)
|
|
}
|
|
|
|
/// `$HOME/.icons`, `$XDG_DATA_HOME/icons`, `$XDG_DATA_DIRS/icons`.
|
|
pub(crate) fn base_dirs() -> Vec<PathBuf> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<PathBuf> {
|
|
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"),
|
|
"<svg/>",
|
|
);
|
|
write(
|
|
&base.join("hicolor/scalable/actions/object-select-symbolic.svg"),
|
|
"<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"), "<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"), "<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"), "<svg/>");
|
|
write(&base.join("theme/48x48/actions/check.svg"), "<svg/>");
|
|
write(&base.join("theme/scalable/actions/check.svg"), "<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"), "<svg/>");
|
|
write(&base.join("theme/48x48/actions/check.svg"), "<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"), "<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"), "<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"),
|
|
"<svg/>",
|
|
);
|
|
write(
|
|
&base.join("hicolor/scalable/actions/object-select-symbolic.svg"),
|
|
"<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());
|
|
}
|
|
}
|
|
}
|