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
+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");
}
}