theme: add linux system theme sources
This commit is contained in:
Generated
+93
@@ -1740,13 +1740,16 @@ name = "gpui-native-theme"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-channel",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"notify",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"serde",
|
"serde",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2341,6 +2344,26 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.11.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2452,6 +2475,26 @@ version = "3.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -2732,6 +2775,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2817,6 +2872,25 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "8.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio",
|
||||||
|
"notify-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-services",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.18.2"
|
version = "4.18.2"
|
||||||
@@ -2831,6 +2905,15 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-types"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3063,6 +3146,16 @@ dependencies = [
|
|||||||
"objc2-metal 0.2.2",
|
"objc2-metal 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-services"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-text"
|
name = "objc2-core-text"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ toml = "0.8"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
async-channel = "2"
|
||||||
|
notify = "8"
|
||||||
|
zbus = "5"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
block2 = "0.6"
|
block2 = "0.6"
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
|
|||||||
@@ -233,8 +233,7 @@ impl SizeTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the medium class's text-field height to the platform's stated
|
/// Sets the medium class's text-field height to the platform's stated value.
|
||||||
/// value, scaling the other classes by the same ratio.
|
|
||||||
pub fn set_input_height(&mut self, medium_input_height: Pixels) {
|
pub fn set_input_height(&mut self, medium_input_height: Pixels) {
|
||||||
let ratio = medium_input_height / self.medium.input_height;
|
let ratio = medium_input_height / self.medium.input_height;
|
||||||
self.small.input_height *= ratio;
|
self.small.input_height *= ratio;
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
|||||||
Box::new(macos::MacosSource::default())
|
Box::new(macos::MacosSource::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||||
|
linux::platform_source()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||||
Box::new(fallback::FallbackSource)
|
Box::new(fallback::FallbackSource)
|
||||||
}
|
}
|
||||||
@@ -25,4 +30,14 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(crate) mod macos;
|
pub(crate) mod macos;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) mod linux;
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
pub(crate) mod kde;
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
pub(crate) mod omarchy;
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
pub(crate) mod portal;
|
||||||
|
|
||||||
pub(crate) mod fallback;
|
pub(crate) mod fallback;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use super::ThemeSource;
|
|||||||
use crate::ThemeVariant;
|
use crate::ThemeVariant;
|
||||||
use crate::system::SystemTheme;
|
use crate::system::SystemTheme;
|
||||||
|
|
||||||
// Constructed only by `platform_source` on non-macOS targets.
|
// Constructed only by `platform_source` on platforms without their own adapter.
|
||||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
#[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))]
|
||||||
pub(crate) struct FallbackSource;
|
pub(crate) struct FallbackSource;
|
||||||
|
|
||||||
impl ThemeSource for FallbackSource {
|
impl ThemeSource for FallbackSource {
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! KDE adapter: reads the color groups Plasma writes into `~/.config/kdeglobals`
|
||||||
|
//! (plus the Plasma 6.2+ `[General] AccentColor`). The parse is pure, so
|
||||||
|
//! fixtures run on every OS; the IO lives behind the Linux cfg.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use gpui::Hsla;
|
||||||
|
|
||||||
|
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
||||||
|
|
||||||
|
/// The colors stated in kdeglobals' `[Colors:*]` groups.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
pub(crate) struct KdeScheme {
|
||||||
|
pub(crate) accent: Option<Hsla>,
|
||||||
|
pub(crate) background: Option<Hsla>,
|
||||||
|
pub(crate) palette: PartialPalette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdeScheme {
|
||||||
|
/// Parses kdeglobals text.
|
||||||
|
pub(crate) fn parse(text: &str) -> Self {
|
||||||
|
let groups = parse_groups(text);
|
||||||
|
let window = groups.get("Colors:Window");
|
||||||
|
let view = groups.get("Colors:View");
|
||||||
|
let button = groups.get("Colors:Button");
|
||||||
|
let selection = groups.get("Colors:Selection");
|
||||||
|
|
||||||
|
let background = window.and_then(|group| group_color(group, "BackgroundNormal"));
|
||||||
|
|
||||||
|
let accent = groups
|
||||||
|
.get("General")
|
||||||
|
.and_then(|group| group.get("AccentColor"))
|
||||||
|
.and_then(|value| color_list(value));
|
||||||
|
|
||||||
|
let palette = PartialPalette {
|
||||||
|
background,
|
||||||
|
foreground: window.and_then(|group| group_color(group, "ForegroundNormal")),
|
||||||
|
surface: view.and_then(|group| group_color(group, "BackgroundNormal")),
|
||||||
|
surface_foreground: view.and_then(|group| group_color(group, "ForegroundNormal")),
|
||||||
|
secondary: button.and_then(|group| group_color(group, "BackgroundNormal")),
|
||||||
|
secondary_foreground: button.and_then(|group| group_color(group, "ForegroundNormal")),
|
||||||
|
muted: window.and_then(|group| group_color(group, "DecorationHover")),
|
||||||
|
muted_foreground: window.and_then(|group| group_color(group, "ForegroundInactive")),
|
||||||
|
destructive: window.and_then(|group| group_color(group, "ForegroundNegative")),
|
||||||
|
success: window.and_then(|group| group_color(group, "ForegroundPositive")),
|
||||||
|
border: window.and_then(|group| group_color(group, "DecorationNormal")),
|
||||||
|
ring: window.and_then(|group| group_color(group, "DecorationFocus")),
|
||||||
|
selection: selection.and_then(|group| group_color(group, "BackgroundNormal")),
|
||||||
|
primary_foreground: selection.and_then(|group| group_color(group, "ForegroundNormal")),
|
||||||
|
..PartialPalette::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
accent,
|
||||||
|
background,
|
||||||
|
palette,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn into_system(
|
||||||
|
mut self,
|
||||||
|
accessibility: crate::system::AccessibilityPreferences,
|
||||||
|
portal_accent: Option<Hsla>,
|
||||||
|
portal_variant: Option<crate::ThemeVariant>,
|
||||||
|
) -> SystemTheme {
|
||||||
|
// Schemes that state no DecorationFocus fall back to the accent.
|
||||||
|
if self.palette.ring.is_none() {
|
||||||
|
self.palette.ring = self.accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemTheme {
|
||||||
|
// The portal states the mode on Plasma 5.21+.
|
||||||
|
//
|
||||||
|
// Older setups fall back to the scheme's background lightness.
|
||||||
|
variant: portal_variant
|
||||||
|
.or(self.background.map(variant_by_lightness))
|
||||||
|
.unwrap_or(crate::ThemeVariant::Light),
|
||||||
|
accent: self.accent.or(portal_accent),
|
||||||
|
palette: self.palette,
|
||||||
|
control_radius: None,
|
||||||
|
input_height: None,
|
||||||
|
root_font_size: None,
|
||||||
|
accessibility,
|
||||||
|
source: SourceKind::Kde,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
|
||||||
|
if background.l >= 0.5 {
|
||||||
|
crate::ThemeVariant::Light
|
||||||
|
} else {
|
||||||
|
crate::ThemeVariant::Dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> {
|
||||||
|
let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
|
||||||
|
let mut section: Option<&str> = None;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix('[') {
|
||||||
|
section = Some(rest.split(']').next().unwrap_or(rest));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Some(section), Some((key, value))) = (section, line.split_once('=')) {
|
||||||
|
groups
|
||||||
|
.entry(section)
|
||||||
|
.or_default()
|
||||||
|
.insert(key.trim(), value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_color<'a>(group: &'a HashMap<&'a str, &'a str>, key: &str) -> Option<Hsla> {
|
||||||
|
color_list(group.get(key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `r,g,b[,a]` with components in 0..=255.
|
||||||
|
fn color_list(value: &str) -> Option<Hsla> {
|
||||||
|
let mut components = value.split(',').map(str::trim);
|
||||||
|
let red = components.next()?.parse::<f32>().ok()?;
|
||||||
|
let green = components.next()?.parse::<f32>().ok()?;
|
||||||
|
let blue = components.next()?.parse::<f32>().ok()?;
|
||||||
|
let alpha = components.next().and_then(|a| a.parse::<f32>().ok());
|
||||||
|
if red < 0.0 || green < 0.0 || blue < 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scale = |component: f32| component / 255.0;
|
||||||
|
|
||||||
|
Some(Hsla::from(gpui::Rgba {
|
||||||
|
r: scale(red),
|
||||||
|
g: scale(green),
|
||||||
|
b: scale(blue),
|
||||||
|
a: alpha.map(|a| a / 255.0).unwrap_or(1.0),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) mod io {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::KdeScheme;
|
||||||
|
|
||||||
|
fn kdeglobals_path() -> Option<PathBuf> {
|
||||||
|
Some(
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)?
|
||||||
|
.join(".config/kdeglobals"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_current() -> Option<KdeScheme> {
|
||||||
|
let text = std::fs::read_to_string(kdeglobals_path()?).ok()?;
|
||||||
|
Some(KdeScheme::parse(&text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BREEZE_DARK: &str = "\
|
||||||
|
[Colors:Button]
|
||||||
|
BackgroundAlternate=30,87,116
|
||||||
|
BackgroundNormal=41,44,48
|
||||||
|
DecorationFocus=61,174,233
|
||||||
|
DecorationHover=61,174,233
|
||||||
|
ForegroundInactive=161,169,177
|
||||||
|
ForegroundNegative=218,68,83
|
||||||
|
ForegroundNormal=252,252,252
|
||||||
|
ForegroundPositive=39,174,96
|
||||||
|
|
||||||
|
[Colors:Selection]
|
||||||
|
BackgroundNormal=61,174,233
|
||||||
|
DecorationFocus=61,174,233
|
||||||
|
ForegroundNormal=252,252,252
|
||||||
|
|
||||||
|
[Colors:View]
|
||||||
|
BackgroundNormal=20,22,24
|
||||||
|
BackgroundAlternate=29,31,34
|
||||||
|
DecorationFocus=61,174,233
|
||||||
|
DecorationHover=61,174,233
|
||||||
|
ForegroundInactive=161,169,177
|
||||||
|
ForegroundNormal=252,252,252
|
||||||
|
|
||||||
|
[Colors:Window]
|
||||||
|
BackgroundNormal=32,35,38
|
||||||
|
DecorationFocus=61,174,233
|
||||||
|
DecorationHover=61,174,233
|
||||||
|
DecorationNormal=49,54,59
|
||||||
|
ForegroundInactive=161,169,177
|
||||||
|
ForegroundNegative=218,68,83
|
||||||
|
ForegroundNormal=252,252,252
|
||||||
|
ForegroundPositive=39,174,96
|
||||||
|
|
||||||
|
[General]
|
||||||
|
ColorScheme=BreezeDark
|
||||||
|
Name[de]=Breeze Dunkel
|
||||||
|
|
||||||
|
[KDE]
|
||||||
|
contrast=4
|
||||||
|
|
||||||
|
[WM]
|
||||||
|
activeBackground=39,44,49
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breeze_groups_map_onto_roles() {
|
||||||
|
let scheme = KdeScheme::parse(BREEZE_DARK);
|
||||||
|
let scale = |component: u32| component as f32 / 255.0;
|
||||||
|
let background = scheme.palette.background.expect("window background");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
gpui::Rgba::from(background),
|
||||||
|
gpui::Rgba {
|
||||||
|
r: scale(32),
|
||||||
|
g: scale(35),
|
||||||
|
b: scale(38),
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.palette.selection,
|
||||||
|
Some(Hsla::from(gpui::Rgba {
|
||||||
|
r: scale(61),
|
||||||
|
g: scale(174),
|
||||||
|
b: scale(233),
|
||||||
|
a: 1.0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.palette.muted_foreground,
|
||||||
|
Some(Hsla::from(gpui::Rgba {
|
||||||
|
r: scale(161),
|
||||||
|
g: scale(169),
|
||||||
|
b: scale(177),
|
||||||
|
a: 1.0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.palette.destructive,
|
||||||
|
Some(Hsla::from(gpui::Rgba {
|
||||||
|
r: scale(218),
|
||||||
|
g: scale(68),
|
||||||
|
b: scale(83),
|
||||||
|
a: 1.0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localized_and_unrelated_keys_are_ignored() {
|
||||||
|
let scheme = KdeScheme::parse(BREEZE_DARK);
|
||||||
|
|
||||||
|
assert!(scheme.accent.is_none());
|
||||||
|
assert_eq!(scheme.palette.foreground, scheme.palette.surface_foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stated_accent_reaches_the_system_theme() {
|
||||||
|
let text =
|
||||||
|
"[General]\nAccentColor=255,100,50\n[Colors:Window]\nBackgroundNormal=32,35,38\n";
|
||||||
|
let scheme = KdeScheme::parse(text);
|
||||||
|
let system = scheme.into_system(Default::default(), None, None);
|
||||||
|
|
||||||
|
let accent = system.accent.expect("stated accent");
|
||||||
|
assert_eq!(
|
||||||
|
accent,
|
||||||
|
Hsla::from(gpui::Rgba {
|
||||||
|
r: 255.0 / 255.0,
|
||||||
|
g: 100.0 / 255.0,
|
||||||
|
b: 50.0 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(system.source, SourceKind::Kde);
|
||||||
|
assert_eq!(system.variant, crate::ThemeVariant::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_portal_accent_covers_the_unset_marker() {
|
||||||
|
let text =
|
||||||
|
"[General]\nAccentColor=-1,-1,-1\n[Colors:Window]\nBackgroundNormal=250,250,250\n";
|
||||||
|
let scheme = KdeScheme::parse(text);
|
||||||
|
assert!(scheme.accent.is_none());
|
||||||
|
|
||||||
|
let portal_accent = Some(Hsla::from(gpui::rgb(0x3584e4)));
|
||||||
|
let system = scheme.into_system(Default::default(), portal_accent, None);
|
||||||
|
assert_eq!(system.accent, portal_accent);
|
||||||
|
assert_eq!(system.variant, crate::ThemeVariant::Light);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_portal_mode_beats_the_lightness_derivation() {
|
||||||
|
let text = "[Colors:Window]\nBackgroundNormal=32,35,38\n";
|
||||||
|
let scheme = KdeScheme::parse(text);
|
||||||
|
let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light));
|
||||||
|
assert_eq!(system.variant, crate::ThemeVariant::Light);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! Linux adapter selection: Omarchy → KDE → portal (GNOME/generic) →
|
||||||
|
//! fallback, per the plan's detection cascade. Omarchy and KDE palettes come
|
||||||
|
//! from their files; the portal supplements mode, accent, and the
|
||||||
|
//! accessibility flags; `gsettings` covers sessions without a portal.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use gpui::Hsla;
|
||||||
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
use super::ThemeSource;
|
||||||
|
use super::kde;
|
||||||
|
use super::omarchy;
|
||||||
|
use super::portal;
|
||||||
|
use crate::ThemeVariant;
|
||||||
|
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
||||||
|
|
||||||
|
pub(crate) struct LinuxSource {
|
||||||
|
backend: Backend,
|
||||||
|
portal: Option<portal::io::PortalClient>,
|
||||||
|
gsettings: Option<GSettingsSnapshot>,
|
||||||
|
file_watcher: Option<notify::RecommendedWatcher>,
|
||||||
|
reapply_task: Option<gpui::Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which payload the source reads.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
enum Backend {
|
||||||
|
Omarchy,
|
||||||
|
Kde,
|
||||||
|
Portal,
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect(home: &Path, portal_reachable: bool, gsettings_reachable: bool) -> Backend {
|
||||||
|
let omarchy_marker = home
|
||||||
|
.join(".local/state/omarchy/current/theme.name")
|
||||||
|
.exists()
|
||||||
|
|| home.join(".local/state/omarchy/current/theme").is_dir()
|
||||||
|
|| home.join(".config/omarchy").is_dir();
|
||||||
|
if omarchy_marker {
|
||||||
|
Backend::Omarchy
|
||||||
|
} else if home.join(".config/kdeglobals").exists() {
|
||||||
|
Backend::Kde
|
||||||
|
} else if portal_reachable || gsettings_reachable {
|
||||||
|
Backend::Portal
|
||||||
|
} else {
|
||||||
|
Backend::Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `gsettings` states for GNOME sessions without a portal.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
struct GSettingsSnapshot {
|
||||||
|
variant: Option<ThemeVariant>,
|
||||||
|
accent: Option<Hsla>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gsettings_value(args: &[&str]) -> Option<String> {
|
||||||
|
let output = std::process::Command::new("gsettings")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The named accent colors GNOME ships (libadwaita's background variants).
|
||||||
|
fn accent_rgb(name: &str) -> Option<Hsla> {
|
||||||
|
let hex = match name {
|
||||||
|
"blue" => 0x3584e4,
|
||||||
|
"teal" => 0x2190a4,
|
||||||
|
"green" => 0x3a944a,
|
||||||
|
"yellow" => 0xc88800,
|
||||||
|
"orange" => 0xed5b00,
|
||||||
|
"red" => 0xe62d42,
|
||||||
|
"pink" => 0xd56199,
|
||||||
|
"purple" => 0x9141ac,
|
||||||
|
"slate" => 0x6f8396,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Hsla::from(gpui::rgb(hex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_gsettings() -> Option<GSettingsSnapshot> {
|
||||||
|
let variant = gsettings_value(&["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||||
|
.and_then(|value| match value.trim().trim_matches('\'') {
|
||||||
|
"prefer-dark" => Some(ThemeVariant::Dark),
|
||||||
|
"prefer-light" => Some(ThemeVariant::Light),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"])
|
||||||
|
.and_then(|value| accent_rgb(value.trim().trim_matches('\'')));
|
||||||
|
|
||||||
|
if variant.is_none() && accent.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GSettingsSnapshot { variant, accent })
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Box::new(LinuxSource {
|
||||||
|
backend,
|
||||||
|
portal,
|
||||||
|
gsettings,
|
||||||
|
file_watcher: None,
|
||||||
|
reapply_task: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSource {
|
||||||
|
fn portal_snapshot(
|
||||||
|
&self,
|
||||||
|
variant: ThemeVariant,
|
||||||
|
portal_appearance: Option<portal::Appearance>,
|
||||||
|
accessibility: crate::system::AccessibilityPreferences,
|
||||||
|
) -> SystemTheme {
|
||||||
|
if let Some(appearance) = portal_appearance {
|
||||||
|
return SystemTheme {
|
||||||
|
variant: appearance.variant(variant),
|
||||||
|
accent: appearance.accent,
|
||||||
|
palette: PartialPalette::default(),
|
||||||
|
control_radius: None,
|
||||||
|
input_height: None,
|
||||||
|
root_font_size: None,
|
||||||
|
accessibility,
|
||||||
|
source: SourceKind::Gnome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(gsettings) = self.gsettings {
|
||||||
|
return SystemTheme {
|
||||||
|
variant: gsettings.variant.unwrap_or(variant),
|
||||||
|
accent: gsettings.accent,
|
||||||
|
palette: PartialPalette::default(),
|
||||||
|
control_radius: None,
|
||||||
|
input_height: None,
|
||||||
|
root_font_size: None,
|
||||||
|
accessibility,
|
||||||
|
source: SourceKind::Gnome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SystemTheme::fallback(variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeSource for LinuxSource {
|
||||||
|
fn snapshot(&self, variant: ThemeVariant) -> SystemTheme {
|
||||||
|
let portal_appearance = self.portal.as_ref().map(|portal| portal.appearance());
|
||||||
|
let accessibility = portal_appearance
|
||||||
|
.map(portal::Appearance::accessibility)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
match self.backend {
|
||||||
|
Backend::Omarchy => omarchy::io::read_current()
|
||||||
|
.map(|theme| theme.into_system(accessibility))
|
||||||
|
.unwrap_or_else(|| SystemTheme::fallback(variant)),
|
||||||
|
Backend::Kde => kde::io::read_current()
|
||||||
|
.map(|scheme| {
|
||||||
|
scheme.into_system(
|
||||||
|
accessibility,
|
||||||
|
portal_appearance.and_then(|appearance| appearance.accent),
|
||||||
|
portal_appearance.map(|appearance| appearance.variant(variant)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| SystemTheme::fallback(variant)),
|
||||||
|
Backend::Portal => self.portal_snapshot(variant, portal_appearance, accessibility),
|
||||||
|
Backend::Fallback => SystemTheme::fallback(variant),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(portal) = &self.portal {
|
||||||
|
portal.spawn_watch(reapply_signal.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.backend {
|
||||||
|
Backend::Omarchy => {
|
||||||
|
let Some(current) = omarchy::io::current_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match watcher {
|
||||||
|
Ok(mut watcher) => {
|
||||||
|
if let Err(error) = watcher.watch(¤t, RecursiveMode::NonRecursive) {
|
||||||
|
eprintln!("theme: omarchy watch failed: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.file_watcher = Some(watcher);
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("theme: omarchy watcher unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Backend::Kde => {
|
||||||
|
let Some(config) = home_dir().map(|home| home.join(".config")) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("theme: kde watch error: {error}"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match watcher {
|
||||||
|
Ok(mut watcher) => {
|
||||||
|
if let Err(error) = watcher.watch(&config, RecursiveMode::NonRecursive) {
|
||||||
|
eprintln!("theme: kde watch failed: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.file_watcher = Some(watcher);
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("theme: kde watcher unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Backend::Portal | Backend::Fallback => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
match self.backend {
|
||||||
|
Backend::Omarchy => SourceKind::Omarchy.label(),
|
||||||
|
Backend::Kde => SourceKind::Kde.label(),
|
||||||
|
Backend::Portal => SourceKind::Gnome.label(),
|
||||||
|
Backend::Fallback => SourceKind::Fallback.label(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_detection_cascade_prefers_the_marker_files() {
|
||||||
|
let home =
|
||||||
|
std::env::temp_dir().join(format!("gpui-native-kit-detect-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&home).expect("temp home");
|
||||||
|
assert_eq!(detect(&home, false, false), Backend::Fallback);
|
||||||
|
assert_eq!(detect(&home, false, true), Backend::Portal);
|
||||||
|
|
||||||
|
std::fs::create_dir_all(home.join(".config/omarchy")).expect("omarchy marker");
|
||||||
|
assert_eq!(detect(&home, false, false), Backend::Omarchy);
|
||||||
|
assert_eq!(detect(&home, true, true), Backend::Omarchy);
|
||||||
|
std::fs::remove_dir_all(home.join(".config/omarchy")).expect("remove marker");
|
||||||
|
|
||||||
|
std::fs::write(home.join(".config/kdeglobals"), "").expect("kdeglobals");
|
||||||
|
assert_eq!(detect(&home, false, false), Backend::Kde);
|
||||||
|
assert_eq!(detect(&home, true, false), Backend::Kde);
|
||||||
|
std::fs::remove_dir_all(&home).expect("cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Omarchy adapter: reads the current theme's `colors.toml` from the state
|
||||||
|
//! directory (`~/.local/state/omarchy/current/theme/`). The parse is pure,
|
||||||
|
//! so fixtures run on every OS; the IO lives behind the Linux cfg.
|
||||||
|
|
||||||
|
use gpui::Hsla;
|
||||||
|
use toml::Table;
|
||||||
|
|
||||||
|
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
||||||
|
use crate::{AccessibilityPreferences, ThemeVariant};
|
||||||
|
|
||||||
|
/// The palette Omarchy deploys with the current theme.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub(crate) struct OmarchyTheme {
|
||||||
|
pub(crate) variant: ThemeVariant,
|
||||||
|
pub(crate) accent: Option<Hsla>,
|
||||||
|
pub(crate) palette: PartialPalette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OmarchyTheme {
|
||||||
|
pub(crate) fn parse(text: &str) -> Option<Self> {
|
||||||
|
let table = text.parse::<Table>().ok()?;
|
||||||
|
|
||||||
|
let background = hex_color(&table, "background")?;
|
||||||
|
let foreground = hex_color(&table, "foreground")?;
|
||||||
|
|
||||||
|
let variant = table
|
||||||
|
.get("mode")
|
||||||
|
.and_then(toml::Value::as_str)
|
||||||
|
.and_then(mode_variant)
|
||||||
|
.unwrap_or_else(|| variant_by_lightness(background));
|
||||||
|
|
||||||
|
let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4"));
|
||||||
|
let selection = hex_color(&table, "selection");
|
||||||
|
let muted_foreground =
|
||||||
|
hex_color(&table, "dark_foreground").or_else(|| hex_color(&table, "color8"));
|
||||||
|
|
||||||
|
let palette = PartialPalette {
|
||||||
|
background: Some(background),
|
||||||
|
foreground: Some(foreground),
|
||||||
|
surface: hex_color(&table, "lighter_background"),
|
||||||
|
surface_foreground: Some(foreground),
|
||||||
|
muted_foreground,
|
||||||
|
destructive: hex_color(&table, "red").or_else(|| hex_color(&table, "color1")),
|
||||||
|
success: hex_color(&table, "green").or_else(|| hex_color(&table, "color2")),
|
||||||
|
warning: hex_color(&table, "yellow").or_else(|| hex_color(&table, "orange")),
|
||||||
|
ring: accent,
|
||||||
|
selection,
|
||||||
|
..PartialPalette::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
variant,
|
||||||
|
accent,
|
||||||
|
palette,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn into_system(self, accessibility: AccessibilityPreferences) -> SystemTheme {
|
||||||
|
SystemTheme {
|
||||||
|
variant: self.variant,
|
||||||
|
accent: self.accent,
|
||||||
|
palette: self.palette,
|
||||||
|
control_radius: None,
|
||||||
|
input_height: None,
|
||||||
|
root_font_size: None,
|
||||||
|
accessibility,
|
||||||
|
source: SourceKind::Omarchy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_variant(mode: &str) -> Option<ThemeVariant> {
|
||||||
|
match mode {
|
||||||
|
"dark" => Some(ThemeVariant::Dark),
|
||||||
|
"light" => Some(ThemeVariant::Light),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variant_by_lightness(background: Hsla) -> ThemeVariant {
|
||||||
|
if background.l >= 0.5 {
|
||||||
|
ThemeVariant::Light
|
||||||
|
} else {
|
||||||
|
ThemeVariant::Dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_color(table: &Table, key: &str) -> Option<Hsla> {
|
||||||
|
let value = table.get(key)?.as_str()?;
|
||||||
|
let digits = value.trim().strip_prefix('#')?;
|
||||||
|
if digits.len() != 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hex = u32::from_str_radix(digits, 16).ok()?;
|
||||||
|
Some(Hsla::from(gpui::rgb(hex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) mod io {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::OmarchyTheme;
|
||||||
|
|
||||||
|
fn home_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory theme-set swaps on every theme change.
|
||||||
|
pub(crate) fn current_dir() -> Option<PathBuf> {
|
||||||
|
Some(home_dir()?.join(".local/state/omarchy/current"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_current() -> Option<OmarchyTheme> {
|
||||||
|
let text = std::fs::read_to_string(
|
||||||
|
home_dir()?.join(".local/state/omarchy/current/theme/colors.toml"),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
OmarchyTheme::parse(&text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TOKYO_NIGHT: &str = "\
|
||||||
|
mode = \"dark\"
|
||||||
|
|
||||||
|
accent = \"#7aa2f7\"
|
||||||
|
selection = \"#292e42\"
|
||||||
|
muted = \"#414868\"
|
||||||
|
|
||||||
|
background = \"#1a1b26\"
|
||||||
|
dark_background = \"#13141c\"
|
||||||
|
darker_background = \"#0e0e14\"
|
||||||
|
lighter_background = \"#24283b\"
|
||||||
|
|
||||||
|
foreground = \"#a9b1d6\"
|
||||||
|
dark_foreground = \"#565f89\"
|
||||||
|
|
||||||
|
red = \"#f7768e\"
|
||||||
|
yellow = \"#e0af68\"
|
||||||
|
orange = \"#eb927b\"
|
||||||
|
green = \"#9ece6a\"
|
||||||
|
";
|
||||||
|
|
||||||
|
const ANSI_GENERATED: &str = "\
|
||||||
|
accent = \"#0060ff\"
|
||||||
|
selection = \"#c1c8d2\"
|
||||||
|
|
||||||
|
background = \"#e8e8ec\"
|
||||||
|
foreground = \"#251f32\"
|
||||||
|
|
||||||
|
color0 = \"#e8e8ec\"
|
||||||
|
color1 = \"#ab2833\"
|
||||||
|
color2 = \"#3f7c38\"
|
||||||
|
color3 = \"#8f6235\"
|
||||||
|
color4 = \"#0060ff\"
|
||||||
|
color5 = \"#8d439d\"
|
||||||
|
color6 = \"#3e7d96\"
|
||||||
|
color7 = \"#251f32\"
|
||||||
|
color8 = \"#625e6a\"
|
||||||
|
color9 = \"#cd3d45\"
|
||||||
|
color10 = \"#44a540\"
|
||||||
|
color11 = \"#c18a41\"
|
||||||
|
color12 = \"#278aff\"
|
||||||
|
color13 = \"#a659b9\"
|
||||||
|
color14 = \"#4b9fc0\"
|
||||||
|
color15 = \"#635d6f\"
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_keys_map_onto_roles() {
|
||||||
|
let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("tokyo-night parses");
|
||||||
|
|
||||||
|
assert_eq!(theme.variant, ThemeVariant::Dark);
|
||||||
|
assert_eq!(theme.accent, Some(Hsla::from(gpui::rgb(0x7aa2f7))));
|
||||||
|
|
||||||
|
let palette = theme.palette;
|
||||||
|
let background = palette.background.expect("background stated");
|
||||||
|
assert_eq!(background, Hsla::from(gpui::rgb(0x1a1b26)));
|
||||||
|
assert_eq!(palette.surface, Some(Hsla::from(gpui::rgb(0x24283b))));
|
||||||
|
assert_eq!(
|
||||||
|
palette.muted_foreground,
|
||||||
|
Some(Hsla::from(gpui::rgb(0x565f89)))
|
||||||
|
);
|
||||||
|
assert_eq!(palette.destructive, Some(Hsla::from(gpui::rgb(0xf7768e))));
|
||||||
|
assert_eq!(palette.success, Some(Hsla::from(gpui::rgb(0x9ece6a))));
|
||||||
|
assert_eq!(palette.warning, Some(Hsla::from(gpui::rgb(0xe0af68))));
|
||||||
|
assert_eq!(palette.selection, Some(Hsla::from(gpui::rgb(0x292e42))));
|
||||||
|
assert_eq!(palette.ring, theme.accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_fallback_derives_the_variant_from_the_background() {
|
||||||
|
let theme = OmarchyTheme::parse(ANSI_GENERATED).expect("ANSI palette parses");
|
||||||
|
|
||||||
|
assert_eq!(theme.variant, ThemeVariant::Light);
|
||||||
|
assert_eq!(theme.accent, Some(Hsla::from(gpui::rgb(0x0060ff))));
|
||||||
|
assert_eq!(
|
||||||
|
theme.palette.destructive,
|
||||||
|
Some(Hsla::from(gpui::rgb(0xab2833)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
theme.palette.muted_foreground,
|
||||||
|
Some(Hsla::from(gpui::rgb(0x625e6a)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stated_mode_beats_the_derivation() {
|
||||||
|
let text = "mode = \"light\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\nforeground = \"#a9b1d6\"\n";
|
||||||
|
let theme = OmarchyTheme::parse(text).expect("parses");
|
||||||
|
assert_eq!(theme.variant, ThemeVariant::Light);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_toml_text_is_rejected() {
|
||||||
|
assert!(OmarchyTheme::parse("not a theme").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! The xdg-desktop-portal Settings reader (`org.freedesktop.appearance`).
|
||||||
|
//! GTK and GNOME toolkits take these values from the same place, so this is
|
||||||
|
//! the canonical source on generic Linux. The value mapping is pure and
|
||||||
|
//! fixture-tested on every OS; the DBus client lives behind the Linux cfg.
|
||||||
|
|
||||||
|
use gpui::Hsla;
|
||||||
|
|
||||||
|
use crate::{ThemeVariant, system::AccessibilityPreferences};
|
||||||
|
|
||||||
|
/// The raw portal values, already converted off the DBus wire types.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
pub(crate) struct AppearanceValues {
|
||||||
|
/// 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||||
|
pub(crate) color_scheme: Option<u32>,
|
||||||
|
/// The `accent-color` sRGB tuple, each component in 0..1.
|
||||||
|
pub(crate) accent_rgb: Option<(f64, f64, f64)>,
|
||||||
|
/// 0 = normal, 1 = higher.
|
||||||
|
pub(crate) contrast: Option<u32>,
|
||||||
|
/// 0 = no preference, 1 = reduced.
|
||||||
|
pub(crate) reduced_motion: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppearanceValues {
|
||||||
|
pub(crate) fn decode(self) -> Appearance {
|
||||||
|
Appearance {
|
||||||
|
color_scheme: self.color_scheme,
|
||||||
|
accent: self.accent_rgb.map(|(r, g, b)| {
|
||||||
|
Hsla::from(gpui::Rgba {
|
||||||
|
r: r as f32,
|
||||||
|
g: g as f32,
|
||||||
|
b: b as f32,
|
||||||
|
a: 1.0,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
contrast: self.contrast.unwrap_or(0),
|
||||||
|
reduced_motion: self.reduced_motion.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the portal states under `org.freedesktop.appearance`.
|
||||||
|
///
|
||||||
|
/// Per the portal spec, unknown values read as unset.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
pub(crate) struct Appearance {
|
||||||
|
pub(crate) color_scheme: Option<u32>,
|
||||||
|
pub(crate) accent: Option<Hsla>,
|
||||||
|
pub(crate) contrast: u32,
|
||||||
|
pub(crate) reduced_motion: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Appearance {
|
||||||
|
pub(crate) fn variant(&self, no_preference: ThemeVariant) -> ThemeVariant {
|
||||||
|
match self.color_scheme {
|
||||||
|
Some(1) => ThemeVariant::Dark,
|
||||||
|
Some(2) => ThemeVariant::Light,
|
||||||
|
_ => no_preference,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn accessibility(self) -> AccessibilityPreferences {
|
||||||
|
AccessibilityPreferences {
|
||||||
|
high_contrast: self.contrast >= 1,
|
||||||
|
reduce_motion: self.reduced_motion >= 1,
|
||||||
|
reduce_transparency: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) mod io {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use zbus::blocking::{Connection, Proxy};
|
||||||
|
use zbus::zvariant::OwnedValue;
|
||||||
|
|
||||||
|
use super::{Appearance, AppearanceValues};
|
||||||
|
|
||||||
|
type AppearanceCache = Arc<Mutex<Appearance>>;
|
||||||
|
|
||||||
|
const SERVICE: &str = "org.freedesktop.portal.Desktop";
|
||||||
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
|
const INTERFACE: &str = "org.freedesktop.portal.Settings";
|
||||||
|
const NAMESPACE: &str = "org.freedesktop.appearance";
|
||||||
|
|
||||||
|
pub(crate) struct PortalClient {
|
||||||
|
appearance: AppearanceCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortalClient {
|
||||||
|
/// Reads the appearance once.
|
||||||
|
pub(crate) fn connect() -> Option<Self> {
|
||||||
|
let proxy = settings_proxy(Connection::session().ok()?)?;
|
||||||
|
let appearance = Arc::new(Mutex::new(read_appearance(&proxy)?));
|
||||||
|
Some(Self { appearance })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recently read appearance.
|
||||||
|
pub(crate) fn appearance(&self) -> Appearance {
|
||||||
|
*self.appearance.lock().expect("portal cache poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread.
|
||||||
|
pub(crate) fn spawn_watch(&self, reapply_signal: async_channel::Sender<()>) {
|
||||||
|
let appearance = self.appearance.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let Some(connection) = Connection::session().ok() else {
|
||||||
|
eprintln!("theme: portal watch skipped, no session bus");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(proxy) = settings_proxy(connection) else {
|
||||||
|
eprintln!("theme: portal watch skipped, settings proxy unavailable");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(signals) = proxy.receive_signal("SettingChanged") else {
|
||||||
|
eprintln!("theme: portal watch skipped, signal subscription failed");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for message in signals {
|
||||||
|
let Ok((namespace, _key, _value)) =
|
||||||
|
message.body().deserialize::<(String, String, OwnedValue)>()
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => eprintln!("theme: portal read failed on SettingChanged"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_proxy(connection: Connection) -> Option<Proxy<'static>> {
|
||||||
|
Proxy::new(&connection, SERVICE, PATH, INTERFACE).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_appearance(proxy: &Proxy<'static>) -> Option<Appearance> {
|
||||||
|
let values: HashMap<String, HashMap<String, OwnedValue>> =
|
||||||
|
proxy.call("ReadAll", &([NAMESPACE],)).ok()?;
|
||||||
|
|
||||||
|
let namespace = values.get(NAMESPACE)?;
|
||||||
|
let field = |key: &str| namespace.get(key);
|
||||||
|
|
||||||
|
Some(
|
||||||
|
AppearanceValues {
|
||||||
|
color_scheme: field("color-scheme")
|
||||||
|
.and_then(|value| u32::try_from(value.clone()).ok()),
|
||||||
|
accent_rgb: field("accent-color")
|
||||||
|
.and_then(|value| <(f64, f64, f64)>::try_from(value.clone()).ok()),
|
||||||
|
contrast: field("contrast").and_then(|value| u32::try_from(value.clone()).ok()),
|
||||||
|
reduced_motion: field("reduced-motion")
|
||||||
|
.and_then(|value| u32::try_from(value.clone()).ok()),
|
||||||
|
}
|
||||||
|
.decode(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_full_appearance_decodes() {
|
||||||
|
let appearance = AppearanceValues {
|
||||||
|
color_scheme: Some(1),
|
||||||
|
accent_rgb: Some((0.48, 0.62, 0.89)),
|
||||||
|
contrast: Some(0),
|
||||||
|
reduced_motion: Some(0),
|
||||||
|
}
|
||||||
|
.decode();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
appearance.variant(crate::ThemeVariant::Light),
|
||||||
|
crate::ThemeVariant::Dark
|
||||||
|
);
|
||||||
|
let accent = appearance.accent.expect("accent decodes");
|
||||||
|
let rgba = gpui::Rgba::from(accent);
|
||||||
|
assert!(rgba.b > rgba.r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_preference_yields_the_callers_variant() {
|
||||||
|
let appearance = AppearanceValues {
|
||||||
|
color_scheme: Some(0),
|
||||||
|
..AppearanceValues::default()
|
||||||
|
}
|
||||||
|
.decode();
|
||||||
|
assert_eq!(
|
||||||
|
appearance.variant(crate::ThemeVariant::Light),
|
||||||
|
crate::ThemeVariant::Light
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unset_scheme_yields_the_callers_variant() {
|
||||||
|
let appearance = AppearanceValues::default().decode();
|
||||||
|
assert_eq!(
|
||||||
|
appearance.variant(crate::ThemeVariant::Dark),
|
||||||
|
crate::ThemeVariant::Dark
|
||||||
|
);
|
||||||
|
assert_eq!(appearance, Appearance::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accessibility_flags_forward() {
|
||||||
|
let accessibility = AppearanceValues {
|
||||||
|
contrast: Some(1),
|
||||||
|
reduced_motion: Some(1),
|
||||||
|
..AppearanceValues::default()
|
||||||
|
}
|
||||||
|
.decode()
|
||||||
|
.accessibility();
|
||||||
|
|
||||||
|
assert!(accessibility.high_contrast);
|
||||||
|
assert!(accessibility.reduce_motion);
|
||||||
|
assert!(!accessibility.reduce_transparency);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,14 @@ pub struct AccessibilityPreferences {
|
|||||||
|
|
||||||
/// Which adapter produced a [`SystemTheme`].
|
/// Which adapter produced a [`SystemTheme`].
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
pub enum SourceKind {
|
pub enum SourceKind {
|
||||||
#[default]
|
#[default]
|
||||||
Fallback,
|
Fallback,
|
||||||
Macos,
|
Macos,
|
||||||
|
Omarchy,
|
||||||
|
Kde,
|
||||||
|
Gnome,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceKind {
|
impl SourceKind {
|
||||||
@@ -25,6 +29,9 @@ impl SourceKind {
|
|||||||
match self {
|
match self {
|
||||||
Self::Fallback => "neutral fallback",
|
Self::Fallback => "neutral fallback",
|
||||||
Self::Macos => "macOS (NSColor)",
|
Self::Macos => "macOS (NSColor)",
|
||||||
|
Self::Omarchy => "Omarchy (colors.toml)",
|
||||||
|
Self::Kde => "KDE (kdeglobals)",
|
||||||
|
Self::Gnome => "GNOME (portal)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -168,11 +168,13 @@ Controls through unit 10 paint from existing roles and ramps.
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 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 |
|
| 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 | WinRT `UISettings` (`AccentColor`, `AccentLight1–3`, `AccentDark1–2`, `Background`, `Foreground`), `TextScaleFactor`, `AccessibilitySettings` high-contrast; `Personalize` registry fallback | `UISettings.ColorValuesChanged` |
|
||||||
| GNOME / generic Linux | portal reachable | portal `org.freedesktop.appearance` (`color-scheme`, `accent-color`, `contrast`, `reduced-motion`); `gsettings` fallback | portal `SettingChanged` |
|
| 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` color groups + scheme hash + accent; portal for mode/accent | inotify on `kdeglobals` |
|
| 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` or `~/.config/omarchy` marker | `current/theme/colors.toml` + `theme.name` (semantic keys or ANSI `color0..15`); portal for contrast/reduced-motion | inotify on the `current` dir (palette edits + symlink replacement) |
|
| 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 |
|
| 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.
|
||||||
|
|
||||||
### 5.4 Accent ramp engine
|
### 5.4 Accent ramp engine
|
||||||
|
|
||||||
Every target supplies one accent color (or a stated ramp). Where the platform states shades (WinUI `AccentLight1–3`/`AccentDark1–2`, KDE scheme ramps) use them as-is; elsewhere derive the state shades components need (hover, pressed, selected, disabled, subtle fills) in OKLCH with fixed lightness/chroma offsets toward the background or foreground, hue preserved. Derived colors compute once per resolve, never per frame.
|
Every target supplies one accent color (or a stated ramp). Where the platform states shades (WinUI `AccentLight1–3`/`AccentDark1–2`, KDE scheme ramps) use them as-is; elsewhere derive the state shades components need (hover, pressed, selected, disabled, subtle fills) in OKLCH with fixed lightness/chroma offsets toward the background or foreground, hue preserved. Derived colors compute once per resolve, never per frame.
|
||||||
@@ -192,7 +194,7 @@ Native feel is half geometry. Per-platform tables for: control heights and paddi
|
|||||||
|
|
||||||
### 5.7 Implementation vehicles
|
### 5.7 Implementation vehicles
|
||||||
|
|
||||||
`objc2` + `objc2-app-kit` (macOS), `windows` (WinRT `UISettings`), `zbus` (portal DBus), `notify` (file watches), `serde` + `toml` (definitions) — all behind the `ThemeSource` seam so the engine core stays platform-free and testable.
|
`objc2` + `objc2-app-kit` (macOS), `windows` (WinRT `UISettings`), `zbus` 5 blocking API on a dedicated thread (portal DBus), `async-channel` (watcher→main-thread reapply bridge), `notify` (file watches), `serde` + `toml` (definitions) — all behind the `ThemeSource` seam so the engine core stays platform-free and testable.
|
||||||
|
|
||||||
### 5.8 What we do differently from native-theme
|
### 5.8 What we do differently from native-theme
|
||||||
|
|
||||||
@@ -323,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.
|
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.
|
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).
|
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. Remaining Phase F: **Windows/GNOME/KDE/Omarchy theme sources** and the **Segoe Fluent/freedesktop icon resolvers**, slotted in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — 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`. 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.
|
||||||
8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted.
|
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.
|
Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
|
||||||
|
|||||||
Reference in New Issue
Block a user