add windows theme source and platform icon resolvers
This commit is contained in:
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'));
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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"
|
||||
|
||||
@@ -22,7 +22,12 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
linux::platform_source()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
windows::platform_source()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
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))]
|
||||
|
||||
@@ -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<portal::io::PortalClient>,
|
||||
gsettings: Option<GSettingsSnapshot>,
|
||||
file_watcher: Option<notify::RecommendedWatcher>,
|
||||
reapply_task: Option<gpui::Task<()>>,
|
||||
/// Keeps the main-thread channel loop alive.
|
||||
_signal: Option<ReapplySignal>,
|
||||
}
|
||||
|
||||
/// Which payload the source reads.
|
||||
@@ -106,11 +108,13 @@ fn read_gsettings() -> Option<GSettingsSnapshot> {
|
||||
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
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<dyn ThemeSource> {
|
||||
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<notify::Event, notify::Error>| 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<notify::Event, notify::Error>| 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 {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<bool>,
|
||||
enable_transparency: Option<bool>,
|
||||
}
|
||||
|
||||
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<ReapplySignal>,
|
||||
}
|
||||
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
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<Self> {
|
||||
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<gpui::Hsla> {
|
||||
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::<UISettings, IInspectable>::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::<AccessibilitySettings, IInspectable>::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()
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user