diff --git a/Cargo.lock b/Cargo.lock index 15c9f60..0bd279a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1721,6 +1721,7 @@ dependencies = [ name = "gpui-native-icons" version = "0.1.0" dependencies = [ + "anyhow", "gpui", ] @@ -1749,6 +1750,8 @@ dependencies = [ "objc2-foundation 0.3.2", "serde", "toml 0.8.23", + "windows 0.62.2", + "windows-registry", "zbus", ] diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml index c85b882..f8f0cf4 100644 --- a/crates/icons/Cargo.toml +++ b/crates/icons/Cargo.toml @@ -5,4 +5,5 @@ edition = "2024" description = "System icons for GPUI: semantic roles resolved per platform, with a bundled Lucide fallback" [dependencies] +anyhow = "1" gpui = { workspace = true } diff --git a/crates/icons/src/freedesktop.rs b/crates/icons/src/freedesktop.rs new file mode 100644 index 0000000..e8addb0 --- /dev/null +++ b/crates/icons/src/freedesktop.rs @@ -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::() { + 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()); + } + } +} diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index 2ee40a4..a9dfbcb 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -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 { + 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>> { + let rest = path.strip_prefix("icons/freedesktop/")?; + let stem = rest.strip_suffix(".svg")?; + let (slug, size) = stem.rsplit_once('-')?; + let size = size.parse::().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>> { + 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"); + } } diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs new file mode 100644 index 0000000..ce11546 --- /dev/null +++ b/crates/icons/src/segoe.rs @@ -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 { + 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}')); + } +} diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 3d7e3d3..e376094 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -13,11 +13,23 @@ toml = "0.8" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] async-channel = "2" + +[target.'cfg(target_os = "linux")'.dependencies] notify = "8" zbus = "5" +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62", features = [ + "Foundation", + "UI", + "UI_ViewManagement", + "Win32_Foundation", + "Win32_System_Com", +] } +windows-registry = "0.6" + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" objc2 = "0.6" diff --git a/crates/theme/src/source.rs b/crates/theme/src/source.rs index a045b49..6e2995e 100644 --- a/crates/theme/src/source.rs +++ b/crates/theme/src/source.rs @@ -22,7 +22,12 @@ pub(crate) fn platform_source() -> Box { linux::platform_source() } -#[cfg(not(any(target_os = "macos", target_os = "linux")))] +#[cfg(target_os = "windows")] +pub(crate) fn platform_source() -> Box { + windows::platform_source() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] pub(crate) fn platform_source() -> Box { Box::new(fallback::FallbackSource) } @@ -33,6 +38,12 @@ pub(crate) mod macos; #[cfg(target_os = "linux")] pub(crate) mod linux; +#[cfg(target_os = "windows")] +pub(crate) mod windows; + +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub(crate) mod signal; + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) mod kde; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] diff --git a/crates/theme/src/source/linux.rs b/crates/theme/src/source/linux.rs index 65019c7..e0ec096 100644 --- a/crates/theme/src/source/linux.rs +++ b/crates/theme/src/source/linux.rs @@ -12,6 +12,7 @@ use super::ThemeSource; use super::kde; use super::omarchy; use super::portal; +use super::signal::ReapplySignal; use crate::ThemeVariant; use crate::system::{PartialPalette, SourceKind, SystemTheme}; @@ -20,7 +21,8 @@ pub(crate) struct LinuxSource { portal: Option, gsettings: Option, file_watcher: Option, - reapply_task: Option>, + /// Keeps the main-thread channel loop alive. + _signal: Option, } /// Which payload the source reads. @@ -106,11 +108,13 @@ fn read_gsettings() -> Option { pub(crate) fn platform_source() -> Box { let portal = portal::io::PortalClient::connect(); + let gsettings = if portal.is_none() { read_gsettings() } else { None }; + let backend = home_dir() .map(|home| detect(&home, portal.is_some(), gsettings.is_some())) .unwrap_or(Backend::Fallback); @@ -120,7 +124,7 @@ pub(crate) fn platform_source() -> Box { portal, gsettings, file_watcher: None, - reapply_task: None, + _signal: None, }) } @@ -185,18 +189,10 @@ impl ThemeSource for LinuxSource { } fn install_watchers(&mut self, async_cx: gpui::AsyncApp) { - let (reapply_signal, reapply_events) = async_channel::unbounded::<()>(); - - async_cx.update(|cx| { - self.reapply_task = Some(cx.spawn(async move |cx| { - while reapply_events.recv().await.is_ok() { - cx.update(crate::reapply); - } - })); - }); + let signal = ReapplySignal::install(&async_cx); if let Some(portal) = &self.portal { - portal.spawn_watch(reapply_signal.clone()); + portal.spawn_watch(signal.sender()); } match self.backend { @@ -204,14 +200,11 @@ impl ThemeSource for LinuxSource { let Some(current) = omarchy::io::current_dir() else { return; }; + let reapply_signal = signal.sender(); let watcher = notify::recommended_watcher( move |event: std::result::Result| match event { - Ok(_) => { - if let Err(error) = reapply_signal.try_send(()) { - eprintln!("theme: omarchy reapply signal failed: {error}"); - } - } + Ok(_) => ReapplySignal::poke(&reapply_signal), Err(error) => eprintln!("theme: omarchy watch error: {error}"), }, ); @@ -231,15 +224,15 @@ impl ThemeSource for LinuxSource { let Some(config) = home_dir().map(|home| home.join(".config")) else { return; }; + let reapply_signal = signal.sender(); let watcher = notify::recommended_watcher( move |event: std::result::Result| match event { Ok(event) => { if event.paths.iter().any(|path| { path.file_name().is_some_and(|name| name == "kdeglobals") - }) && let Err(error) = reapply_signal.try_send(()) - { - eprintln!("theme: kde reapply signal failed: {error}"); + }) { + ReapplySignal::poke(&reapply_signal); } } Err(error) => eprintln!("theme: kde watch error: {error}"), @@ -258,6 +251,8 @@ impl ThemeSource for LinuxSource { } Backend::Portal | Backend::Fallback => {} } + + self._signal = Some(signal); } fn label(&self) -> &'static str { diff --git a/crates/theme/src/source/portal.rs b/crates/theme/src/source/portal.rs index f5507e9..2de322c 100644 --- a/crates/theme/src/source/portal.rs +++ b/crates/theme/src/source/portal.rs @@ -127,15 +127,15 @@ pub(crate) mod io { else { continue; }; + if namespace != NAMESPACE { continue; } + match read_appearance(&proxy) { Some(latest) => { *appearance.lock().expect("portal cache poisoned") = latest; - if let Err(error) = reapply_signal.try_send(()) { - eprintln!("theme: portal reapply signal failed: {error}"); - } + super::super::signal::ReapplySignal::poke(&reapply_signal); } None => eprintln!("theme: portal read failed on SettingChanged"), } diff --git a/crates/theme/src/source/signal.rs b/crates/theme/src/source/signal.rs new file mode 100644 index 0000000..6279fc4 --- /dev/null +++ b/crates/theme/src/source/signal.rs @@ -0,0 +1,38 @@ +//! Wakes the main-thread resolve pipeline from watcher threads, which hold +//! no gpui context (the pinned gpui's `AsyncApp` is not `Send`). + +use gpui::{AsyncApp, Task}; + +pub(crate) struct ReapplySignal { + sender: async_channel::Sender<()>, + _task: Task<()>, +} + +impl ReapplySignal { + pub(crate) fn install(async_cx: &AsyncApp) -> Self { + let (sender, events) = async_channel::unbounded::<()>(); + let task = async_cx.update(|cx| { + cx.spawn(async move |cx| { + while events.recv().await.is_ok() { + cx.update(crate::reapply); + } + }) + }); + Self { + sender, + _task: task, + } + } + + /// A clone for a watcher thread to poke the pipeline with. + pub(crate) fn sender(&self) -> async_channel::Sender<()> { + self.sender.clone() + } + + /// Pokes the pipeline from a thread holding a [`Self::sender`] clone. + pub(crate) fn poke(sender: &async_channel::Sender<()>) { + if let Err(error) = sender.try_send(()) { + eprintln!("theme: reapply signal failed: {error}"); + } + } +} diff --git a/crates/theme/src/source/windows.rs b/crates/theme/src/source/windows.rs new file mode 100644 index 0000000..a6f2df0 --- /dev/null +++ b/crates/theme/src/source/windows.rs @@ -0,0 +1,199 @@ +//! Windows 11 adapter: WinRT `UISettings` semantic colors with a +//! `Personalize` registry fallback, watched through `ColorValuesChanged`. + +use windows::Foundation::TypedEventHandler; +use windows::UI::ViewManagement::{AccessibilitySettings, UIColorType, UISettings}; +use windows::Win32::Foundation::RPC_E_CHANGED_MODE; +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx}; +use windows::core::IInspectable; + +use super::ThemeSource; +use super::signal::ReapplySignal; +use crate::ThemeVariant; +use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme}; + +/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling. +const CONTROL_RADIUS_PX: f32 = 4.0; +/// WinUI 11's default text-box height, in pixels at 100% scaling. +const INPUT_HEIGHT_PX: f32 = 32.0; +/// WinUI 11's default body text size, in pixels at 100% scaling. +const ROOT_FONT_SIZE_PX: f32 = 14.0; + +/// The `Personalize` registry values, read once at startup as the fallback +/// when the WinRT surface is unavailable. +#[derive(Clone, Copy, Debug, Default)] +struct RegistrySettings { + apps_use_light_theme: Option, + enable_transparency: Option, +} + +impl RegistrySettings { + fn read() -> Self { + let personalize = || { + windows_registry::CURRENT_USER + .open(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") + }; + Self { + apps_use_light_theme: personalize() + .and_then(|key| key.get_u32("AppsUseLightTheme")) + .ok() + .map(|value| value != 0), + enable_transparency: personalize() + .and_then(|key| key.get_u32("EnableTransparency")) + .ok() + .map(|value| value != 0), + } + } +} + +pub(crate) struct WindowsSource { + ui_settings: UISettings, + accessibility_settings: AccessibilitySettings, + registry: RegistrySettings, + /// Keeps the main-thread channel loop alive. + _signal: Option, +} + +pub(crate) fn platform_source() -> Box { + let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + + if initialized.is_err() && initialized != RPC_E_CHANGED_MODE { + eprintln!("theme: COM initialization failed: {initialized:?}"); + } + + match WindowsSource::new() { + Some(source) => Box::new(source), + None => { + eprintln!("theme: Windows system theme unavailable; using fallback"); + Box::new(super::fallback::FallbackSource) + } + } +} + +impl WindowsSource { + fn new() -> Option { + let ui_settings = match UISettings::new() { + Ok(ui_settings) => ui_settings, + Err(error) => { + eprintln!("theme: UISettings unavailable: {error}"); + return None; + } + }; + + let accessibility_settings = match AccessibilitySettings::new() { + Ok(accessibility_settings) => accessibility_settings, + Err(error) => { + eprintln!("theme: AccessibilitySettings unavailable: {error}"); + return None; + } + }; + + Some(Self { + ui_settings, + accessibility_settings, + registry: RegistrySettings::read(), + _signal: None, + }) + } + + fn color(&self, kind: UIColorType) -> Option { + let color = self.ui_settings.GetColorValue(kind).ok()?; + Some(gpui::Hsla::from(gpui::Rgba { + r: color.R as f32 / 255.0, + g: color.G as f32 / 255.0, + b: color.B as f32 / 255.0, + a: color.A as f32 / 255.0, + })) + } + + fn accessibility(&self) -> AccessibilityPreferences { + AccessibilityPreferences { + high_contrast: self.accessibility_settings.HighContrast().unwrap_or(false), + // Windows states animations separately in Win32; not read yet. + reduce_motion: false, + reduce_transparency: self + .registry + .enable_transparency + .map(|enabled| !enabled) + .unwrap_or(false), + } + } +} + +impl ThemeSource for WindowsSource { + fn snapshot(&self, variant: ThemeVariant) -> SystemTheme { + let accent = self.color(UIColorType::Accent); + let background = self.color(UIColorType::Background); + let foreground = self.color(UIColorType::Foreground); + + let variant = background + .map(|background| { + if background.l >= 0.5 { + ThemeVariant::Light + } else { + ThemeVariant::Dark + } + }) + .or(self.registry.apps_use_light_theme.map(|light| { + if light { + ThemeVariant::Light + } else { + ThemeVariant::Dark + } + })) + .unwrap_or(variant); + + let text_scale = self.ui_settings.TextScaleFactor().unwrap_or(1.0); + + SystemTheme { + variant, + accent, + // Windows states its ramp through UIColorType's accent variants. + palette: PartialPalette { + background, + foreground, + // Windows paints keyboard focus with the accent itself. + ring: accent, + ..PartialPalette::default() + }, + control_radius: Some(gpui::px(CONTROL_RADIUS_PX)), + input_height: Some(gpui::px(INPUT_HEIGHT_PX)), + root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)), + accessibility: self.accessibility(), + source: SourceKind::Windows, + } + } + + fn install_watchers(&mut self, async_cx: gpui::AsyncApp) { + let signal = ReapplySignal::install(&async_cx); + let color_signal = signal.sender(); + let color_handler = TypedEventHandler::::new(move |_, _| { + ReapplySignal::poke(&color_signal); + Ok(()) + }); + + if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) { + eprintln!("theme: color change watch failed: {error}"); + } + + let contrast_signal = signal.sender(); + let contrast_handler = + TypedEventHandler::::new(move |_, _| { + ReapplySignal::poke(&contrast_signal); + Ok(()) + }); + + if let Err(error) = self + .accessibility_settings + .HighContrastChanged(&contrast_handler) + { + eprintln!("theme: high contrast watch failed: {error}"); + } + + self._signal = Some(signal); + } + + fn label(&self) -> &'static str { + SourceKind::Windows.label() + } +} diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index b9bd6d4..67ab497 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -14,7 +14,7 @@ pub struct AccessibilityPreferences { /// Which adapter produced a [`SystemTheme`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))] pub enum SourceKind { #[default] Fallback, @@ -22,6 +22,7 @@ pub enum SourceKind { Omarchy, Kde, Gnome, + Windows, } impl SourceKind { @@ -32,6 +33,7 @@ impl SourceKind { Self::Omarchy => "Omarchy (colors.toml)", Self::Kde => "KDE (kdeglobals)", Self::Gnome => "GNOME (portal)", + Self::Windows => "Windows 11 (UISettings)", } } } diff --git a/docs/PLAN.md b/docs/PLAN.md index 6580bd7..6c01efc 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -100,9 +100,9 @@ One-way, no cycles. Nothing depends on `gpui-component`, `gpui-kit`, `gpuikit`, - **`IconRole`** — a semantic enum of the glyphs UI actually needs (~25 to start): chevrons, check, close, plus/minus, search, folder, warning/info/success, copy, arrow-left/right, calendar, clock, settings, dots-horizontal, … Components and apps reference roles, never glyph names. - **Resolver cascade per platform**, behind one `Icon` element: - - macOS: SF Symbols via `NSImage(systemSymbolName:)`, rasterized through AppKit at (size, weight, appearance, tint) and cached; never bundled. - - Windows: Segoe Fluent Icons codepoints rendered as text (system font). - - Linux/Omarchy: freedesktop icon-theme lookup (active theme → hicolor), SVG painted via gpui's `svg()`. + - macOS: SF Symbols via `NSImage(systemSymbolName:)`, rasterized through AppKit at (size, weight, appearance, tint) and cached; never bundled. Deferred — macOS rides the Lucide fallback until it shows it matters. + - Windows: Segoe Fluent Icons codepoints rendered as text (system font). ✅ Landed (2026-10-30): role→codepoint table transcribed from Microsoft's glyph list (Close uses ChromeClose E8BB, not Cancel E711), drawn at the icon's size in `Segoe Fluent Icons`; unverified on hardware until the Windows screenshot pass. + - Linux/Omarchy: freedesktop icon-theme lookup (active theme → hicolor), SVG painted via gpui's `svg()`. ✅ Landed (2026-10-30): bounded walk over `$HOME/.icons` + the XDG data dirs handling both real layouts (`size/context/` for Adwaita and Papirus, `context/size/` for Breeze); `scalable` wins, else the closest numbered size; inherits chain capped at five themes, then `hicolor`; theme name read from Omarchy's `icons.theme` → `kdeglobals [Icons]` → gsettings → Adwaita; results cached per (role, size) for the process; only `.svg` matches (PNG-only themes fall through); a miss falls back to the bundled Lucide. Resolver fixtures run on every OS. - Fallback everywhere: a bundled Lucide subset (ISC; attribution in NOTICE) — ships first so components work before platform resolvers land. - Public API stays tiny: `icon(role)` → element with `.size(...)`; tint defaults to theme tokens. A missing glyph logs once and falls back — never panics. - Gallery page: icon browser showing every role resolved on the current platform. @@ -167,13 +167,13 @@ Controls through unit 10 paint from existing roles and ramps. | Target | Detection | Sources read | Live changes | | --- | --- | --- | --- | | macOS | compile target | `NSColor` semantic palette (`windowBackgroundColor`, `labelColor`, `controlColor`, `controlAccentColor`, `selectedTextBackgroundColor`, `keyboardFocusIndicatorColor`, …) in the effective appearance via `objc2-app-kit`; `NSWorkspace` accessibility flags | distributed notifications + GPUI appearance events | -| Windows 11 (WinUI) | compile target | WinRT `UISettings` (`AccentColor`, `AccentLight1–3`, `AccentDark1–2`, `Background`, `Foreground`), `TextScaleFactor`, `AccessibilitySettings` high-contrast; `Personalize` registry fallback | `UISettings.ColorValuesChanged` | +| Windows 11 (WinUI) | compile target | ✅ Landed (2026-10-30): WinRT `UISettings` (`UIColorType` Background/Foreground/Accent — the stated AccentLight/Dark variants wait until the engine takes a full ramp, D13), `TextScaleFactor` scales the root font, `AccessibilitySettings` high-contrast; `Personalize` registry fallback (mode + transparency only — the accent key is skipped). WinUI metrics: 4px control radius, 32px field height, 14px root font. Reduce-motion not read yet (Win32 SPI). Verified against the windows 0.62 bindings; a full cross-build waits for hardware (gpui's manifest embedding needs llvm-rc) | `UISettings.ColorValuesChanged` + `HighContrastChanged` | | GNOME / generic Linux | portal reachable (else `gsettings`) | portal `org.freedesktop.appearance` (`color-scheme`, `accent-color` as `(ddd)`, `contrast`, `reduced-motion`); `gsettings` fallback (`color-scheme` name + the libadwaita accent-name table) | portal `SettingChanged` | | KDE | `kdeglobals` present | `kdeglobals` `[Colors:*]` groups (Window/View/Button/Selection) + `[General] AccentColor` (`-1,-1,-1` = wallpaper-derived, covered by the portal); portal for mode/accent | inotify on `~/.config`, filtered to `kdeglobals` (survives kconfig's replace-on-write) | | Omarchy | `~/.local/state/omarchy` marker (`theme.name`, `current/theme`, or `~/.config/omarchy`) | `current/theme/colors.toml` (semantic keys, or the ANSI `color0..15` fallback the alacritty generator writes); portal for contrast/reduced-motion; mode from the file's `mode` key or background lightness | inotify on the `current` dir — catches theme-set's whole-directory swaps and `theme.name` writes (hand edits of the deployed copy need a switch to re-fire) | | Fallback / unknown | none of the above, or reader failure | bundled neutral light/dark theme | manual only | -Linux adapters landed 2026-10-30 (fixture-tested parsers on every OS; payload facts transcribed from the portal spec, plasma's Breeze schemes, omarchy's `bin/omarchy-theme-set`, and libadwaita). The pinned gpui's `AsyncApp` is not `Send`, so watcher threads signal through an `async_channel` and a main-thread `App::spawn` loop reapplies. +Linux adapters landed 2026-10-30 (fixture-tested parsers on every OS; payload facts transcribed from the portal spec, plasma's Breeze schemes, omarchy's `bin/omarchy-theme-set`, and libadwaita). The pinned gpui's `AsyncApp` is not `Send`, so watcher threads signal through the shared `source/signal.rs` bridge — an `async_channel` whose drain loop runs on the main thread via `App::spawn` — which the Windows events reuse. ### 5.4 Accent ramp engine @@ -325,7 +325,7 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). -7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. Remaining Phase F: the **Windows source (WinRT `UISettings`)** and the **Segoe Fluent/freedesktop icon resolvers**, slotted in as verification environments allow — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput/multiline return as deferred units when needed. 8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.