add windows theme source and platform icon resolvers

This commit is contained in:
2026-10-31 08:56:57 +07:00
parent c45f939211
commit d53f4e290d
13 changed files with 895 additions and 36 deletions
+403
View File
@@ -0,0 +1,403 @@
//! 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());
}
}
}
+156 -4
View File
@@ -1,9 +1,10 @@
//! System icons for GPUI: components and apps reference semantic roles,
//! and the crate resolves each role to a glyph for the current platform.
//!
//! Today every role resolves to the bundled Lucide fallback. Platform
//! resolvers land with their phases: SF Symbols (macOS), Segoe Fluent
//! Icons (Windows), freedesktop icon themes (Linux/Omarchy).
//! Resolver cascade (plan §4.4): Segoe Fluent Icons (Windows), freedesktop
//! icon themes (Linux/Omarchy), and the bundled Lucide subset everywhere
//! else — macOS rides the Lucide fallback until SF Symbols shows it matters.
//! A missing glyph falls back silently; nothing ever fails to paint.
use gpui::prelude::FluentBuilder as _;
use gpui::{
@@ -11,6 +12,11 @@ use gpui::{
Styled as _, Window, div, px, svg,
};
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod freedesktop;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod segoe;
/// The semantic glyphs UI components need.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IconRole {
@@ -62,6 +68,73 @@ impl IconRole {
Self::Info => "icons/info.svg",
}
}
fn slug(self) -> &'static str {
match self {
Self::Check => "check",
Self::Close => "close",
Self::ChevronDown => "chevron-down",
Self::ChevronLeft => "chevron-left",
Self::ChevronRight => "chevron-right",
Self::ChevronUp => "chevron-up",
Self::Plus => "plus",
Self::Minus => "minus",
Self::Search => "search",
Self::Folder => "folder",
Self::Warning => "warning",
Self::Info => "info",
}
}
/// Freedesktop names the role resolves through, best first.
///
/// Symbolic names lead (Adwaita), with Breeze and legacy names as fallbacks.
fn freedesktop_names(self) -> &'static [&'static str] {
match self {
Self::Check => &[
"object-select-symbolic",
"object-select",
"emblem-ok-symbolic",
"checkmark",
],
Self::Close => &[
"window-close-symbolic",
"window-close",
"close-symbolic",
"gtk-close",
],
Self::ChevronDown => &[
"pan-down-symbolic",
"pan-down",
"go-down-symbolic",
"arrow-down",
],
Self::ChevronLeft => &[
"pan-start-symbolic",
"pan-start",
"go-previous-symbolic",
"arrow-left",
],
Self::ChevronRight => &[
"pan-end-symbolic",
"pan-end",
"go-next-symbolic",
"arrow-right",
],
Self::ChevronUp => &["pan-up-symbolic", "pan-up", "go-up-symbolic", "arrow-up"],
Self::Plus => &["list-add-symbolic", "list-add", "add"],
Self::Minus => &["list-remove-symbolic", "list-remove", "remove"],
Self::Search => &[
"system-search-symbolic",
"system-search",
"search",
"edit-find",
],
Self::Folder => &["folder-symbolic", "folder"],
Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"],
Self::Info => &["dialog-information-symbolic", "dialog-information", "info"],
}
}
}
/// An icon element. Unpainted unless a color is set.
@@ -91,14 +164,61 @@ impl Icon {
self.color = Some(color);
self
}
/// The asset key a resolved freedesktop file is served under.
#[cfg(target_os = "linux")]
fn freedesktop_asset_path(&self) -> Option<SharedString> {
let size = self.size.as_f32().max(1.0).round() as u32;
freedesktop::cached_file(self.role, size).map(|_| {
SharedString::from(format!("icons/freedesktop/{}-{size}.svg", self.role.slug()))
})
}
}
impl RenderOnce for Icon {
fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement {
#[cfg(target_os = "windows")]
return self.render_glyph();
#[cfg(not(target_os = "windows"))]
return self.render_svg();
}
}
impl Icon {
/// Windows: the Segoe Fluent codepoint drawn as text in the system font.
#[cfg(target_os = "windows")]
fn render_glyph(self) -> impl IntoElement {
div()
.flex_none()
.size(self.size)
.flex()
.items_center()
.justify_center()
.when_some(segoe::glyph(self.role), |el, glyph| {
el.child(
div()
.font_family(segoe::FONT_FAMILY)
.text_size(self.size)
.when_some(self.color, |el, color| el.text_color(color))
.child(glyph.to_string()),
)
})
}
#[cfg(not(target_os = "windows"))]
fn render_svg(self) -> impl IntoElement {
#[cfg(target_os = "linux")]
let path: SharedString = self
.freedesktop_asset_path()
.unwrap_or_else(|| SharedString::from(self.role.asset_path()));
#[cfg(not(target_os = "linux"))]
let path: SharedString = SharedString::from(self.role.asset_path());
div().flex_none().size(self.size).child(
svg()
.size_full()
.path(self.role.asset_path())
.path(path)
.when_some(self.color, |el, color| el.text_color(color)),
)
}
@@ -125,8 +245,23 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> {
}
}
fn freedesktop_file(path: &str) -> Option<std::io::Result<Vec<u8>>> {
let rest = path.strip_prefix("icons/freedesktop/")?;
let stem = rest.strip_suffix(".svg")?;
let (slug, size) = stem.rsplit_once('-')?;
let size = size.parse::<u32>().ok()?;
let role = IconRole::all().iter().find(|role| role.slug() == slug)?;
freedesktop::cached_file(*role, size).map(std::fs::read)
}
impl AssetSource for IconAssets {
fn load(&self, path: &str) -> gpui::Result<Option<std::borrow::Cow<'static, [u8]>>> {
if let Some(file) = freedesktop_file(path) {
let bytes = file.map_err(|error| {
anyhow::anyhow!("failed to read freedesktop icon {path}: {error}")
})?;
return Ok(Some(std::borrow::Cow::Owned(bytes)));
}
Ok(asset_bytes(path).map(std::borrow::Cow::Borrowed))
}
@@ -155,4 +290,21 @@ mod tests {
);
}
}
#[test]
fn freedesktop_asset_keys_round_trip() {
let role = IconRole::ChevronDown;
let key = format!("icons/freedesktop/{}-16.svg", role.slug());
let stem = key
.strip_prefix("icons/freedesktop/")
.unwrap()
.strip_suffix(".svg")
.unwrap();
let (slug, parsed_size) = stem.rsplit_once('-').unwrap();
let resolved = IconRole::all()
.iter()
.find(|candidate| candidate.slug() == slug);
assert_eq!(resolved, Some(&role));
assert_eq!(parsed_size, "16");
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Windows resolver: each role maps to a Segoe Fluent Icons codepoint,
//! rendered as text in the system font (never bundled).
use crate::IconRole;
/// The Segoe Fluent Icons codepoint for the role.
pub(crate) fn glyph(role: IconRole) -> Option<char> {
let codepoint = match role {
IconRole::Check => 0xE73E, // CheckMark
IconRole::Close => 0xE8BB, // ChromeClose
IconRole::ChevronDown => 0xE70D, // ChevronDown
IconRole::ChevronLeft => 0xE76B, // ChevronLeft
IconRole::ChevronRight => 0xE76C, // ChevronRight
IconRole::ChevronUp => 0xE70E, // ChevronUp
IconRole::Plus => 0xE710, // Add
IconRole::Minus => 0xE738, // Remove
IconRole::Search => 0xE721, // Search
IconRole::Folder => 0xE8B7, // Folder
IconRole::Warning => 0xE7BA, // Warning
IconRole::Info => 0xE946, // Info
};
char::from_u32(codepoint)
}
/// The system font the codepoints resolve against (Windows 11+).
pub(crate) const FONT_FAMILY: &str = "Segoe Fluent Icons";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_role_maps_to_a_codepoint() {
for role in crate::IconRole::all() {
assert!(glyph(*role).is_some(), "missing codepoint for {role:?}");
}
}
#[test]
fn the_close_role_uses_the_chrome_close_glyph() {
assert_eq!(glyph(IconRole::Close), Some('\u{E8BB}'));
}
}