From 12d4120bfd8fc899fedf4a176567d1e8ba331a7e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 28 Oct 2026 10:54:02 +0700 Subject: [PATCH] theme: build out the theme engine core with resolve pipeline --- Cargo.lock | 7 + crates/theme/Cargo.toml | 24 ++ crates/theme/presets/tokyo-night.toml | 21 ++ crates/theme/src/definition.rs | 190 ++++++++++++++ crates/theme/src/lib.rs | 142 ++++++++-- crates/theme/src/presets.rs | 23 ++ crates/theme/src/ramps.rs | 273 ++++++++++++++++++++ crates/theme/src/resolve.rs | 356 ++++++++++++++++++++++++++ crates/theme/src/source.rs | 28 ++ crates/theme/src/source/fallback.rs | 20 ++ crates/theme/src/source/macos.rs | 139 ++++++++++ crates/theme/src/system.rs | 72 ++++++ docs/PLAN.md | 8 +- examples/gallery/src/main.rs | 168 ++++++++++-- 14 files changed, 1434 insertions(+), 37 deletions(-) create mode 100644 crates/theme/presets/tokyo-night.toml create mode 100644 crates/theme/src/definition.rs create mode 100644 crates/theme/src/presets.rs create mode 100644 crates/theme/src/ramps.rs create mode 100644 crates/theme/src/resolve.rs create mode 100644 crates/theme/src/source.rs create mode 100644 crates/theme/src/source/fallback.rs create mode 100644 crates/theme/src/source/macos.rs create mode 100644 crates/theme/src/system.rs diff --git a/Cargo.lock b/Cargo.lock index 9bb97b4..19cfc48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1614,7 +1614,14 @@ dependencies = [ name = "gpui-native-theme" version = "0.1.0" dependencies = [ + "anyhow", + "block2 0.6.2", "gpui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "serde", + "toml 0.8.23", ] [[package]] diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 8f0cd03..4177384 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -5,4 +5,28 @@ edition = "2024" description = "Native theme engine: reads the OS theme and resolves it into semantic tokens" [dependencies] +anyhow = "1" gpui = { workspace = true } +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "NSAccessibility", + "NSAppearance", + "NSColor", + "NSColorSpace", + "NSWorkspace", + "block2", + "objc2-core-foundation", +] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = [ + "NSDistributedNotificationCenter", + "NSNotification", + "NSOperation", + "NSString", + "block2", + "std", +] } diff --git a/crates/theme/presets/tokyo-night.toml b/crates/theme/presets/tokyo-night.toml new file mode 100644 index 0000000..696a5c2 --- /dev/null +++ b/crates/theme/presets/tokyo-night.toml @@ -0,0 +1,21 @@ +# Tokyo Night. Schema shared with user theme files. + +mode = "dark" +background = "#1a1b26" +foreground = "#c0caf5" +surface = "#1f2335" +surface_foreground = "#c0caf5" +primary = "#7aa2f7" +primary_foreground = "#16161e" +secondary = "#292e42" +secondary_foreground = "#c0caf5" +muted = "#292e42" +muted_foreground = "#a9b1d6" +accent = "#7aa2f7" +accent_foreground = "#16161e" +destructive = "#f7768e" +destructive_foreground = "#16161e" +border = "#292e42" +input = "#292e42" +ring = "#7aa2f7" +selection = "#33467c" diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs new file mode 100644 index 0000000..06bbde4 --- /dev/null +++ b/crates/theme/src/definition.rs @@ -0,0 +1,190 @@ +//! Sparse theme definitions: the shared schema for bundled presets and +//! user theme files. Any field may be omitted; omitted fields fall through +//! to the system theme, then to the neutral presets. + +use gpui::{Hsla, Rgba}; +use serde::Deserialize; + +use crate::ThemeVariant; + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct ThemeDefinition { + pub mode: Option, + pub accent: Option, + pub accent_foreground: Option, + pub background: Option, + pub foreground: Option, + pub surface: Option, + pub surface_foreground: Option, + pub primary: Option, + pub primary_foreground: Option, + pub secondary: Option, + pub secondary_foreground: Option, + pub muted: Option, + pub muted_foreground: Option, + pub destructive: Option, + pub destructive_foreground: Option, + pub border: Option, + pub input: Option, + pub ring: Option, + pub selection: Option, +} + +impl ThemeDefinition { + pub fn parse_toml(source: &str) -> anyhow::Result { + Ok(toml::from_str(source)?) + } + + pub fn accent(&self) -> Option { + parse_hex(self.accent.as_deref()?) + } + + pub fn accent_foreground(&self) -> Option { + parse_hex(self.accent_foreground.as_deref()?) + } + + pub fn background(&self) -> Option { + parse_hex(self.background.as_deref()?) + } + + pub fn foreground(&self) -> Option { + parse_hex(self.foreground.as_deref()?) + } + + pub fn surface(&self) -> Option { + parse_hex(self.surface.as_deref()?) + } + + pub fn surface_foreground(&self) -> Option { + parse_hex(self.surface_foreground.as_deref()?) + } + + pub fn primary(&self) -> Option { + parse_hex(self.primary.as_deref()?) + } + + pub fn primary_foreground(&self) -> Option { + parse_hex(self.primary_foreground.as_deref()?) + } + + pub fn secondary(&self) -> Option { + parse_hex(self.secondary.as_deref()?) + } + + pub fn secondary_foreground(&self) -> Option { + parse_hex(self.secondary_foreground.as_deref()?) + } + + pub fn muted(&self) -> Option { + parse_hex(self.muted.as_deref()?) + } + + pub fn muted_foreground(&self) -> Option { + parse_hex(self.muted_foreground.as_deref()?) + } + + pub fn destructive(&self) -> Option { + parse_hex(self.destructive.as_deref()?) + } + + pub fn destructive_foreground(&self) -> Option { + parse_hex(self.destructive_foreground.as_deref()?) + } + + pub fn border(&self) -> Option { + parse_hex(self.border.as_deref()?) + } + + pub fn input(&self) -> Option { + parse_hex(self.input.as_deref()?) + } + + pub fn ring(&self) -> Option { + parse_hex(self.ring.as_deref()?) + } + + pub fn selection(&self) -> Option { + parse_hex(self.selection.as_deref()?) + } +} + +/// Parses `#rgb` or `#rrggbb` into an opaque color. +pub(crate) fn parse_hex(value: &str) -> Option { + let digits = value.strip_prefix('#')?; + let expand = |channels: [f32; 3]| Some(expand_rgb([channels[0], channels[1], channels[2]])); + + match digits.len() { + 3 => { + let mut channels = [0u8; 3]; + for (i, byte) in channels.iter_mut().enumerate() { + *byte = u8::from_str_radix(&digits[i..i + 1], 16).ok()?; + } + expand([ + channels[0] as f32 / 15.0, + channels[1] as f32 / 15.0, + channels[2] as f32 / 15.0, + ]) + } + 6 => { + let mut channels = [0u8; 3]; + for (i, byte) in channels.iter_mut().enumerate() { + *byte = u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).ok()?; + } + expand([ + channels[0] as f32 / 255.0, + channels[1] as f32 / 255.0, + channels[2] as f32 / 255.0, + ]) + } + _ => None, + } +} + +fn expand_rgb([r, g, b]: [f32; 3]) -> Hsla { + Hsla::from(Rgba { r, g, b, a: 1.0 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_six_and_three_digit_hex() { + let Rgba { r, g, b, a } = Rgba::from(parse_hex("#7aa2f7").unwrap()); + assert!((r - 0x7a as f32 / 255.0).abs() < 0.001); + assert!((g - 0xa2 as f32 / 255.0).abs() < 0.001); + assert!((b - 0xf7 as f32 / 255.0).abs() < 0.001); + assert_eq!(a, 1.0); + + // Compare in RGB space: Rgba -> Hsla -> Rgba is lossy at low saturation. + let short = Rgba::from(parse_hex("#f00").unwrap()); + assert!((short.r - 1.0).abs() < 0.001); + assert!(short.g.abs() < 0.001); + assert!(short.b.abs() < 0.001); + } + + #[test] + fn rejects_malformed_values() { + assert!(parse_hex("").is_none()); + assert!(parse_hex("#12").is_none()); + assert!(parse_hex("#12345").is_none()); + assert!(parse_hex("#zzzzzz").is_none()); + assert!(parse_hex("7aa2f7").is_none()); + } + + #[test] + fn parses_a_full_definition() { + let def = ThemeDefinition::parse_toml( + "mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\n", + ) + .unwrap(); + assert_eq!(def.mode, Some(crate::ThemeVariant::Dark)); + assert!(def.accent().is_some()); + assert!(def.foreground().is_none()); + } + + #[test] + fn an_invalid_mode_fails_the_parse_instead_of_being_ignored() { + assert!(ThemeDefinition::parse_toml("mode = \"blue\"\n").is_err()); + } +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 6c94c05..c618d9c 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -1,17 +1,30 @@ -//! The theme engine: reads the running system theme and resolves it into the -//! semantic tokens every component paints from. +//! The theme engine: reads the running system theme, resolves it through +//! presets and overrides, and installs the tokens every component paints +//! from. //! -//! Phase B replaces the bundled light/dark presets with platform adapters -//! (`NSColor` / `UISettings` / portal / Omarchy) behind a `ThemeSource` seam. +//! Pipeline (plan §4.5): `ThemeSource` snapshot → `resolve` overlay chain → +//! `Theme` global → all windows repaint. Watchers re-run the pipeline on +//! real OS change events. mod colors; +mod definition; +mod presets; +mod ramps; +mod resolve; +mod source; +mod system; pub use colors::ColorTokens; +pub use definition::ThemeDefinition; +pub use ramps::{AccentShades, Oklch}; +pub use resolve::{Ramps, ResolvedTheme}; +pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme}; use gpui::{App, Global, WindowAppearance}; /// Light or dark, resolved from the system appearance. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum ThemeVariant { #[default] Light, @@ -32,6 +45,8 @@ impl ThemeVariant { pub struct Theme { pub variant: ThemeVariant, pub colors: ColorTokens, + pub ramps: Ramps, + pub accessibility: AccessibilityPreferences, } impl Global for Theme {} @@ -42,27 +57,120 @@ impl Theme { cx.global::() } - pub fn for_variant(variant: ThemeVariant) -> Self { + fn from_resolved(resolved: ResolvedTheme) -> Self { Self { - variant, - colors: match variant { - ThemeVariant::Light => ColorTokens::light(), - ThemeVariant::Dark => ColorTokens::dark(), - }, + variant: resolved.variant, + colors: resolved.colors, + ramps: resolved.ramps, + accessibility: resolved.accessibility, } } - /// Installs the theme global for the current system appearance. + /// Installs the engine global, its platform watchers, and the initial resolved theme. pub fn init(cx: &mut App) { - let variant = ThemeVariant::from_appearance(cx.window_appearance()); - cx.set_global(Self::for_variant(variant)); + let mut engine = ThemeEngine { + source: source::platform_source(), + mode_override: None, + preset: None, + }; + engine.source.install_watchers(cx.to_async()); + cx.set_global(engine); + reapply(cx); } - /// Re-installs the global when a window's appearance differs from it. + /// Re-resolves when a window's appearance differs from the resolved variant. + /// + /// Wire to `Window::observe_window_appearance`. pub fn sync_from_window(window: &gpui::Window, cx: &mut App) { let variant = ThemeVariant::from_appearance(window.appearance()); - if cx.global::().variant != variant { - cx.set_global(Self::for_variant(variant)); + if cx.global::().variant != variant + || cx.global::().mode_override.is_none() + { + reapply(cx); } } } + +/// The engine's knobs and its platform source. +struct ThemeEngine { + source: Box, + mode_override: Option, + preset: Option<(&'static str, ThemeDefinition)>, +} + +impl Global for ThemeEngine {} + +/// Reads the system, resolves through the current knobs, +/// installs the theme global, and repaints every window. +pub(crate) fn reapply(cx: &mut App) { + let (system_appearance, preset, mode_override) = { + let engine = cx.global::(); + ( + ThemeVariant::from_appearance(cx.window_appearance()), + engine.preset.as_ref().map(|(_, def)| def.clone()), + engine.mode_override, + ) + }; + + let variant = mode_override + .or(preset.as_ref().and_then(|def| def.mode)) + .unwrap_or(system_appearance); + + let system = cx.global::().source.snapshot(variant); + let resolved = resolve::resolve(&system, preset.as_ref(), mode_override); + + cx.set_global(Theme::from_resolved(resolved)); + cx.refresh_windows(); +} + +/// Forces a variant, independent of the OS. `None` follows the system. +pub fn set_mode_override(variant: Option, cx: &mut App) { + cx.global_mut::().mode_override = variant; + reapply(cx); +} + +/// Installs a built-in preset, or `None` to follow the system again. +pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> { + let preset = match name { + Some(name) => { + let def = + presets::builtin(name).ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?; + let static_name = presets::preset_names() + .iter() + .copied() + .find(|candidate| *candidate == name) + .ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?; + Some((static_name, def)) + } + None => None, + }; + cx.global_mut::().preset = preset; + reapply(cx); + Ok(()) +} + +/// The variant the mode override forces, if any. +pub fn mode_override(cx: &App) -> Option { + cx.global::().mode_override +} + +/// The active preset name, if one is installed. +pub fn active_preset(cx: &App) -> Option<&'static str> { + cx.global::() + .preset + .as_ref() + .map(|(name, _)| *name) +} + +/// Clears the mode override and preset; the theme follows the system again. +pub fn follow_system(cx: &mut App) { + let engine = cx.global_mut::(); + engine.mode_override = None; + engine.preset = None; + reapply(cx); +} + +/// Which adapter produced the current system snapshot. +pub fn detected_source(cx: &App) -> &'static str { + cx.global::().source.label() +} diff --git a/crates/theme/src/presets.rs b/crates/theme/src/presets.rs new file mode 100644 index 0000000..b9366eb --- /dev/null +++ b/crates/theme/src/presets.rs @@ -0,0 +1,23 @@ +//! Built-in `ThemeDefinition` presets (D11). + +use crate::definition::ThemeDefinition; + +const TOKYO_NIGHT: &str = include_str!("../presets/tokyo-night.toml"); + +pub fn preset_names() -> &'static [&'static str] { + &["tokyo-night"] +} + +/// Parses the named preset. +pub fn builtin(name: &str) -> Option { + match name { + "tokyo-night" => match ThemeDefinition::parse_toml(TOKYO_NIGHT) { + Ok(def) => Some(def), + Err(err) => { + eprintln!("bundled preset tokyo-night failed to parse: {err}"); + None + } + }, + _ => None, + } +} diff --git a/crates/theme/src/ramps.rs b/crates/theme/src/ramps.rs new file mode 100644 index 0000000..8567791 --- /dev/null +++ b/crates/theme/src/ramps.rs @@ -0,0 +1,273 @@ +//! OKLCH color math for accent-ramp derivation and contrast-aware +//! foreground selection. Matrices follow Björn Ottosson's OKLab reference. +// The reference matrices are quoted at their published precision; f32 +// truncation is deliberate, not a mistake. +#![allow(clippy::excessive_precision)] + +use gpui::{Hsla, Rgba}; + +/// A color in the OKLCH space (perceptually uniform): +/// lightness, chroma, hue in degrees, plus the alpha carried through unchanged. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklch { + pub l: f32, + pub c: f32, + pub h: f32, + pub a: f32, +} + +impl Oklch { + pub fn from_hsla(color: Hsla) -> Self { + let Rgba { r, g, b, a } = Rgba::from(color); + let (l, c, h) = rgb_to_oklch(r, g, b); + Self { l, c, h, a } + } + + pub fn to_hsla(self) -> Hsla { + let (r, g, b) = oklch_to_rgb(self.l, self.c, self.h); + Hsla::from(Rgba { r, g, b, a: self.a }) + } + + /// Shifts lightness by `amount` (positive is lighter), + /// and scales chroma by `chroma_scale`, preserving hue and alpha. + pub fn shifted(self, amount: f32, chroma_scale: f32) -> Self { + Self { + l: (self.l + amount).clamp(0.0, 1.0), + c: (self.c * chroma_scale).max(0.0), + h: self.h, + a: self.a, + } + } +} + +/// Mixes `a` toward `b` by `t` in OKLCH, taking the shortest arc between hues. +/// +/// `t = 0` returns `a`, `t = 1` returns `b`. +pub fn mix(a: Oklch, b: Oklch, t: f32) -> Oklch { + let mut dh = b.h - a.h; + + if dh > 180.0 { + dh -= 360.0; + } else if dh < -180.0 { + dh += 360.0; + } + + Oklch { + l: a.l + (b.l - a.l) * t, + c: a.c + (b.c - a.c) * t, + h: (a.h + dh * t + 360.0) % 360.0, + a: a.a + (b.a - a.a) * t, + } +} + +fn rgb_to_oklch(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let (r, g, b) = (linearize(r), linearize(g), linearize(b)); + let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + let (l_, m_, s_) = (cbrt(l), cbrt(m), cbrt(s)); + let lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + let lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + let lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + + let c = (lab_a * lab_a + lab_b * lab_b).sqrt(); + let mut h = lab_b.atan2(lab_a).to_degrees(); + + if h < 0.0 { + h += 360.0; + } + + (lab_l, c, h) +} + +fn oklch_to_rgb(l: f32, c: f32, h: f32) -> (f32, f32, f32) { + let hr = h.to_radians(); + let lab_a = c * hr.cos(); + let lab_b = c * hr.sin(); + + let l_ = l + 0.3963377774 * lab_a + 0.2158037573 * lab_b; + let m_ = l - 0.1055613458 * lab_a - 0.0638541728 * lab_b; + let s_ = l - 0.0894841775 * lab_a - 1.2914855480 * lab_b; + + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + + let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + + (delinearize(r), delinearize(g), delinearize(b)) +} + +fn linearize(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn delinearize(c: f32) -> f32 { + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn cbrt(v: f32) -> f32 { + if v >= 0.0 { v.cbrt() } else { -(-v).cbrt() } +} + +/// Calculate the foreground color that provides sufficient contrast against the background. +pub fn contrast_foreground(background: Hsla) -> Hsla { + const MIN_CONTRAST: f32 = 3.0; + + let Rgba { r, g, b, .. } = Rgba::from(background); + + let luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); + let white_contrast = 1.05 / (luminance + 0.05); + let black_contrast = (luminance + 0.05) / 0.05; + + let white = Hsla::from(Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }); + + let black = Hsla::from(Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }); + + if white_contrast >= MIN_CONTRAST || white_contrast >= black_contrast { + white + } else { + black + } +} + +/// The state shades components paint from for one accent color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AccentShades { + pub base: Hsla, + pub hover: Hsla, + pub pressed: Hsla, + pub subtle: Hsla, + pub foreground: Hsla, +} + +/// Derives hover/pressed/subtle shades for an accent against its background. +/// +/// Where a platform states ramps (WinUI, KDE) those are used instead, +pub fn derive_shades(accent: Hsla, background: Hsla, is_dark: bool) -> AccentShades { + let accent_ok = Oklch::from_hsla(accent); + let step = if is_dark { 0.05 } else { -0.05 }; + let bg = Oklch::from_hsla(background); + AccentShades { + base: accent, + hover: accent_ok.shifted(step, 1.05).to_hsla(), + pressed: accent_ok.shifted(step * 2.0, 1.1).to_hsla(), + subtle: mix(accent_ok, bg, 0.88).to_hsla(), + foreground: contrast_foreground(accent), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::rgb; + + fn hsla(hex: u32) -> Hsla { + Hsla::from(rgb(hex)) + } + + #[test] + fn white_and_black_have_extreme_lightness_and_no_chroma() { + let white = Oklch::from_hsla(hsla(0xffffff)); + assert!((white.l - 1.0).abs() < 0.001, "white L = {}", white.l); + assert!(white.c < 0.001, "white C = {}", white.c); + + let black = Oklch::from_hsla(hsla(0x000000)); + assert!(black.l.abs() < 0.001, "black L = {}", black.l); + } + + #[test] + fn oklch_round_trips_within_one_step_of_eight_bit_color() { + for hex in [0x2563eb, 0xdc2626, 0x7aa2f7, 0x1a1b26, 0xf7768e] { + let ok = Oklch::from_hsla(hsla(hex)); + let Rgba { r, g, b, .. } = Rgba::from(ok.to_hsla()); + let (er, eg, eb) = { + let expected = Rgba::from(hsla(hex)); + (expected.r, expected.g, expected.b) + }; + assert!((r - er).abs() < 1.0 / 255.0, "{hex:06x} r {r} vs {er}"); + assert!((g - eg).abs() < 1.0 / 255.0, "{hex:06x} g {g} vs {eg}"); + assert!((b - eb).abs() < 1.0 / 255.0, "{hex:06x} b {b} vs {eb}"); + } + } + + #[test] + fn shades_move_toward_the_background_in_light_mode() { + let accent = hsla(0x2563eb); + let background = hsla(0xffffff); + let shades = derive_shades(accent, background, false); + let hover_l = Oklch::from_hsla(shades.hover).l; + let base_l = Oklch::from_hsla(accent).l; + assert!( + hover_l < base_l, + "light-mode hover darkens: {hover_l} vs {base_l}" + ); + + let dark_shades = derive_shades(accent, hsla(0x09090b), true); + let dark_hover_l = Oklch::from_hsla(dark_shades.hover).l; + assert!(dark_hover_l > base_l, "dark-mode hover lightens"); + } + + #[test] + fn subtle_stays_between_accent_and_background() { + let accent = Oklch::from_hsla(hsla(0x2563eb)); + let background = Oklch::from_hsla(hsla(0xffffff)); + let subtle = Oklch::from_hsla(derive_shades(hsla(0x2563eb), hsla(0xffffff), false).subtle); + assert!(subtle.l > accent.l && subtle.l < background.l); + } + + #[test] + fn contrast_foreground_follows_native_fill_conventions() { + // Dark fills get white. + assert_eq!(contrast_foreground(hsla(0x101010)), hsla(0xffffff)); + // macOS blue accent keeps white, like AppKit, even though black has + // the higher raw WCAG ratio. + assert_eq!(contrast_foreground(hsla(0x007aff)), hsla(0xffffff)); + // Light fills — white fails the 3:1 bar and black wins. + assert_eq!(contrast_foreground(hsla(0xefefef)), hsla(0x000000)); + assert_eq!(contrast_foreground(hsla(0xffd60a)), hsla(0x000000)); + } + + #[test] + fn mix_takes_the_shortest_arc_around_the_hue_wheel() { + let a = Oklch { + l: 0.5, + c: 0.1, + h: 350.0, + a: 1.0, + }; + let b = Oklch { + l: 0.5, + c: 0.1, + h: 10.0, + a: 1.0, + }; + let mid = mix(a, b, 0.5); + assert!( + mid.h < 10.0 || mid.h > 350.0, + "mid hue should cross 0/360, got {}", + mid.h + ); + } +} diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs new file mode 100644 index 0000000..95e5ae7 --- /dev/null +++ b/crates/theme/src/resolve.rs @@ -0,0 +1,356 @@ +//! The resolve pipeline: platform snapshot + preset definition + mode +//! override -> fully populated tokens. +//! +//! Pure and unit-testable without a GUI. + +use crate::definition::ThemeDefinition; +use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; +use crate::system::SystemTheme; +use crate::{ColorTokens, ThemeVariant}; + +/// Derived state shades for the colors that get pressed and hovered. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ramps { + pub primary: AccentShades, + pub destructive: AccentShades, +} + +/// Everything a component paints from, after the overlay chain. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTheme { + pub variant: ThemeVariant, + pub colors: ColorTokens, + pub ramps: Ramps, + pub accessibility: crate::system::AccessibilityPreferences, +} + +/// Overlay order (later wins): neutral base → system → preset definition. +pub(crate) fn resolve( + system: &SystemTheme, + preset: Option<&ThemeDefinition>, + mode_override: Option, +) -> ResolvedTheme { + let variant = mode_override + .or(preset.and_then(|def| def.mode)) + .unwrap_or(system.variant); + + let base = match variant { + ThemeVariant::Light => ColorTokens::light(), + ThemeVariant::Dark => ColorTokens::dark(), + }; + + let system_palette = system.palette; + let pick = |def: Option, system: Option, base: gpui::Hsla| { + def.or(system).unwrap_or(base) + }; + + let def_color = |field: fn(&ThemeDefinition) -> Option| preset.and_then(field); + + let accent = pick(def_color(|d| d.accent()), system.accent, base.accent); + let primary = pick(def_color(|d| d.primary()), system.accent, base.primary); + + let destructive = pick( + def_color(|d| d.destructive()), + system_palette.destructive, + base.destructive, + ); + + let mut colors = ColorTokens { + background: pick( + def_color(|d| d.background()), + system_palette.background, + base.background, + ), + foreground: pick( + def_color(|d| d.foreground()), + system_palette.foreground, + base.foreground, + ), + surface: pick( + def_color(|d| d.surface()), + system_palette.surface, + base.surface, + ), + surface_foreground: pick( + def_color(|d| d.surface_foreground()), + system_palette.surface_foreground, + base.surface_foreground, + ), + primary, + primary_foreground: pick( + def_color(|d| d.primary_foreground()), + system_palette.primary_foreground, + contrast_foreground(primary), + ), + secondary: pick( + def_color(|d| d.secondary()), + system_palette.secondary, + base.secondary, + ), + secondary_foreground: pick( + def_color(|d| d.secondary_foreground()), + system_palette.secondary_foreground, + base.secondary_foreground, + ), + muted: pick(def_color(|d| d.muted()), system_palette.muted, base.muted), + muted_foreground: pick( + def_color(|d| d.muted_foreground()), + system_palette.muted_foreground, + base.muted_foreground, + ), + accent, + accent_foreground: pick( + def_color(|d| d.accent_foreground()), + system_palette.accent_foreground, + contrast_foreground(accent), + ), + destructive, + destructive_foreground: pick( + def_color(|d| d.destructive_foreground()), + None, + contrast_foreground(destructive), + ), + border: pick( + def_color(|d| d.border()), + system_palette.border, + base.border, + ), + input: pick(def_color(|d| d.input()), system_palette.input, base.input), + ring: pick(def_color(|d| d.ring()), system_palette.ring, base.ring), + selection: pick( + def_color(|d| d.selection()), + system_palette.selection, + base.selection, + ), + }; + + let accessibility = system.accessibility; + if accessibility.high_contrast { + colors.border = mix( + Oklch::from_hsla(colors.border), + Oklch::from_hsla(colors.foreground), + 0.35, + ) + .to_hsla(); + } + + ResolvedTheme { + variant, + ramps: Ramps { + primary: derive_shades(primary, colors.background, variant == ThemeVariant::Dark), + destructive: derive_shades( + destructive, + colors.background, + variant == ThemeVariant::Dark, + ), + }, + colors, + accessibility, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind}; + use gpui::{Rgba, rgb}; + + fn hsla(hex: u32) -> gpui::Hsla { + gpui::Hsla::from(rgb(hex)) + } + + fn system( + variant: ThemeVariant, + palette: PartialPalette, + accent: Option, + ) -> SystemTheme { + SystemTheme { + variant, + accent, + palette, + accessibility: AccessibilityPreferences::default(), + source: SourceKind::Macos, + } + } + + #[test] + fn empty_system_resolves_to_the_neutral_fallback() { + for variant in [ThemeVariant::Light, ThemeVariant::Dark] { + let resolved = resolve( + &system(variant, PartialPalette::default(), None), + None, + None, + ); + let base = match variant { + ThemeVariant::Light => ColorTokens::light(), + ThemeVariant::Dark => ColorTokens::dark(), + }; + assert_eq!(resolved.colors.background, base.background); + assert_eq!(resolved.colors.accent, base.accent); + assert_eq!(resolved.ramps.primary.base, base.primary); + } + } + + #[test] + fn system_colors_override_the_base_but_leave_the_rest_intact() { + let palette = PartialPalette { + background: Some(hsla(0x112233)), + border: Some(hsla(0x445566)), + ..PartialPalette::default() + }; + let resolved = resolve( + &system(ThemeVariant::Dark, palette, Some(hsla(0xff8800))), + None, + None, + ); + assert_eq!(resolved.colors.background, hsla(0x112233)); + assert_eq!(resolved.colors.border, hsla(0x445566)); + assert_eq!(resolved.colors.accent, hsla(0xff8800)); + assert_eq!(resolved.colors.foreground, ColorTokens::dark().foreground); + } + + #[test] + fn a_system_stated_accent_foreground_beats_the_derivation() { + // What macOS reports via alternateSelectedControlTextColor: white, + // even where the derivation would pick black (light accent). + let palette = PartialPalette { + accent_foreground: Some(hsla(0xffffff)), + ..PartialPalette::default() + }; + let resolved = resolve( + &system(ThemeVariant::Light, palette, Some(hsla(0xffd60a))), + None, + None, + ); + assert_eq!(resolved.colors.accent, hsla(0xffd60a)); + assert_eq!(resolved.colors.accent_foreground, hsla(0xffffff)); + // Without the stated foreground the derivation applies. + let derived = resolve( + &system( + ThemeVariant::Light, + PartialPalette::default(), + Some(hsla(0xffd60a)), + ), + None, + None, + ); + assert_eq!(derived.colors.accent_foreground, hsla(0x000000)); + } + + #[test] + fn preset_beats_system_and_the_mode_override_beats_everything() { + let def = ThemeDefinition::parse_toml( + "mode = \"dark\"\nbackground = \"#1a1b26\"\naccent = \"#7aa2f7\"\n", + ) + .unwrap(); + let palette = PartialPalette { + background: Some(hsla(0xffffff)), + ..PartialPalette::default() + }; + let resolved = resolve( + &system(ThemeVariant::Light, palette, None), + Some(&def), + None, + ); + assert_eq!(resolved.variant, ThemeVariant::Dark); + // Compare in RGB space: Hsla round-trips are lossy at low saturation. + let bg = Rgba::from(resolved.colors.background); + assert!((bg.r - 0x1a as f32 / 255.0).abs() < 0.001); + assert!((bg.b - 0x26 as f32 / 255.0).abs() < 0.001); + let accent = Rgba::from(resolved.colors.accent); + assert!((accent.r - 0x7a as f32 / 255.0).abs() < 0.001); + + let forced = resolve( + &system(ThemeVariant::Light, palette, None), + Some(&def), + Some(ThemeVariant::Light), + ); + assert_eq!(forced.variant, ThemeVariant::Light); + // The override flips the variant (and with it the base roles), but + // the preset's explicit background still wins over the base. + let forced_bg = Rgba::from(forced.colors.background); + assert!((forced_bg.r - 0x1a as f32 / 255.0).abs() < 0.001); + } + + #[test] + fn an_accent_only_override_derives_foregrounds_and_ramps() { + let def = ThemeDefinition::parse_toml("mode = \"dark\"\naccent = \"#7aa2f7\"\n").unwrap(); + let resolved = resolve( + &system(ThemeVariant::Dark, PartialPalette::default(), None), + Some(&def), + None, + ); + // The definition sets accent only; primary still falls back to the + // dark base. accent_foreground/primary_foreground are derived per + // color, and the ramp tracks its own base. + let accent = Rgba::from(resolved.colors.accent); + assert!((accent.r - 0x7a as f32 / 255.0).abs() < 0.001); + assert_eq!( + resolved.colors.primary, + ColorTokens::dark().primary, + "primary should stay at the base until a definition names it" + ); + assert_eq!(resolved.ramps.primary.base, resolved.colors.primary); + assert_eq!(resolved.ramps.destructive.base, resolved.colors.destructive); + assert_ne!(resolved.ramps.primary.hover, resolved.ramps.primary.base); + assert_ne!(resolved.ramps.primary.subtle, resolved.ramps.primary.base); + } + + #[test] + fn system_accent_becomes_primary() { + let resolved = resolve( + &system( + ThemeVariant::Dark, + PartialPalette::default(), + Some(hsla(0x7aa2f7)), + ), + None, + None, + ); + let primary = Rgba::from(resolved.colors.primary); + assert!((primary.r - 0x7a as f32 / 255.0).abs() < 0.001); + assert_eq!(resolved.ramps.primary.base, resolved.colors.primary); + assert_eq!( + resolved.colors.primary_foreground, + resolved.colors.accent_foreground + ); + } + + #[test] + fn high_contrast_sharpens_the_border_toward_the_foreground() { + let mut a11y = AccessibilityPreferences::default(); + let plain = resolve( + &system(ThemeVariant::Dark, PartialPalette::default(), None), + None, + None, + ); + a11y.high_contrast = true; + let contrasted = resolve( + &SystemTheme { + accessibility: a11y, + ..system(ThemeVariant::Dark, PartialPalette::default(), None) + }, + None, + None, + ); + let plain_border = Oklch::from_hsla(plain.colors.border); + let contrasted_border = Oklch::from_hsla(contrasted.colors.border); + let foreground_l = Oklch::from_hsla(plain.colors.foreground).l; + assert!((contrasted_border.l - foreground_l).abs() < (plain_border.l - foreground_l).abs()); + } + + #[test] + fn every_resolved_role_is_distinct_enough_to_paint_with() { + for variant in [ThemeVariant::Light, ThemeVariant::Dark] { + let resolved = resolve( + &system(variant, PartialPalette::default(), None), + None, + None, + ); + assert_ne!(resolved.colors.background, resolved.colors.foreground); + assert_ne!(resolved.colors.border, resolved.colors.background); + assert_ne!(resolved.colors.muted_foreground, resolved.colors.foreground); + assert!(resolved.colors.ring != resolved.colors.background); + } + } +} diff --git a/crates/theme/src/source.rs b/crates/theme/src/source.rs new file mode 100644 index 0000000..6eeb393 --- /dev/null +++ b/crates/theme/src/source.rs @@ -0,0 +1,28 @@ +//! Platform adapters behind one seam: `snapshot()` reads the running +//! system, `install_watchers()` forwards real change events. + +use gpui::AsyncApp; + +use crate::ThemeVariant; +use crate::system::SystemTheme; + +pub(crate) trait ThemeSource: 'static { + fn snapshot(&self, variant: ThemeVariant) -> SystemTheme; + fn install_watchers(&mut self, _async_cx: AsyncApp) {} + fn label(&self) -> &'static str; +} + +#[cfg(target_os = "macos")] +pub(crate) fn platform_source() -> Box { + Box::new(macos::MacosSource::default()) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn platform_source() -> Box { + Box::new(fallback::FallbackSource) +} + +#[cfg(target_os = "macos")] +pub(crate) mod macos; + +pub(crate) mod fallback; diff --git a/crates/theme/src/source/fallback.rs b/crates/theme/src/source/fallback.rs new file mode 100644 index 0000000..6482185 --- /dev/null +++ b/crates/theme/src/source/fallback.rs @@ -0,0 +1,20 @@ +//! The adapter for unknown systems: +//! no colors of its own, resolution degrades to the neutral presets. + +use super::ThemeSource; +use crate::ThemeVariant; +use crate::system::SystemTheme; + +// Constructed only by `platform_source` on non-macOS targets. +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub(crate) struct FallbackSource; + +impl ThemeSource for FallbackSource { + fn snapshot(&self, variant: ThemeVariant) -> SystemTheme { + SystemTheme::fallback(variant) + } + + fn label(&self) -> &'static str { + crate::system::SourceKind::Fallback.label() + } +} diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs new file mode 100644 index 0000000..7a37d9e --- /dev/null +++ b/crates/theme/src/source/macos.rs @@ -0,0 +1,139 @@ +//! macOS Adapter +//! +//! Reads AppKit's semantic colors in the effective appearance and watches +//! the system's color preferences. + +use objc2::rc::Retained; +use objc2::runtime::{NSObjectProtocol, ProtocolObject}; +use objc2_app_kit::{ + NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSColor, NSColorSpace, + NSWorkspace, +}; +use objc2_foundation::{ + NSDistributedNotificationCenter, NSNotification, NSNotificationName, NSOperationQueue, +}; +use std::ptr::NonNull; + +use super::ThemeSource; +use crate::ThemeVariant; +use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme}; + +#[derive(Default)] +pub(crate) struct MacosSource { + _observers: Vec>>, +} + +impl MacosSource { + fn color_to_hsla(color: &NSColor) -> Option { + let srgb = NSColorSpace::sRGBColorSpace(); + let converted = color.colorUsingColorSpace(&srgb)?; + + Some(gpui::Hsla::from(gpui::Rgba { + r: converted.redComponent() as f32, + g: converted.greenComponent() as f32, + b: converted.blueComponent() as f32, + a: converted.alphaComponent() as f32, + })) + } + + /// Resolves the semantic colors with `variant` as the current drawing appearance. + fn snapshot_in_appearance(&self, variant: ThemeVariant) -> PartialPalette { + let name = match variant { + ThemeVariant::Light => { + // SAFETY: extern statics exported by AppKit, always initialized. + unsafe { NSAppearanceNameAqua } + } + ThemeVariant::Dark => { + // SAFETY: as above. + unsafe { NSAppearanceNameDarkAqua } + } + }; + + let Some(appearance) = NSAppearance::appearanceNamed(name) else { + return self.read_palette(); + }; + + let palette = std::cell::Cell::new(PartialPalette::default()); + appearance.performAsCurrentDrawingAppearance(&block2::RcBlock::new(|| { + palette.set(self.read_palette()); + })); + + palette.into_inner() + } + + fn read_palette(&self) -> PartialPalette { + PartialPalette { + background: Self::color_to_hsla(&NSColor::windowBackgroundColor()), + foreground: Self::color_to_hsla(&NSColor::labelColor()), + surface: Self::color_to_hsla(&NSColor::textBackgroundColor()), + surface_foreground: Self::color_to_hsla(&NSColor::labelColor()), + primary_foreground: Self::color_to_hsla(&NSColor::alternateSelectedControlTextColor()), + accent_foreground: Self::color_to_hsla(&NSColor::alternateSelectedControlTextColor()), + secondary: None, + secondary_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), + muted: None, + muted_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), + destructive: Self::color_to_hsla(&NSColor::systemRedColor()), + border: Self::color_to_hsla(&NSColor::separatorColor()), + input: Self::color_to_hsla(&NSColor::controlBackgroundColor()), + ring: Self::color_to_hsla(&NSColor::keyboardFocusIndicatorColor()), + selection: Self::color_to_hsla(&NSColor::selectedTextBackgroundColor()), + } + } +} + +impl ThemeSource for MacosSource { + fn snapshot(&self, variant: ThemeVariant) -> SystemTheme { + let workspace = NSWorkspace::sharedWorkspace(); + + let accessibility = AccessibilityPreferences { + high_contrast: workspace.accessibilityDisplayShouldIncreaseContrast(), + reduce_motion: workspace.accessibilityDisplayShouldReduceMotion(), + reduce_transparency: workspace.accessibilityDisplayShouldReduceTransparency(), + }; + + let palette = self.snapshot_in_appearance(variant); + + SystemTheme { + variant, + accent: Self::color_to_hsla(&NSColor::controlAccentColor()), + palette, + accessibility, + source: SourceKind::Macos, + } + } + + /// Fires on accent and appearance changes (System Settings > Appearance) + /// and re-runs the resolve pipeline through `async_cx`. + fn install_watchers(&mut self, async_cx: gpui::AsyncApp) { + let center = NSDistributedNotificationCenter::defaultCenter(); + let queue = NSOperationQueue::mainQueue(); + + for name in [ + "AppleColorPreferencesChangedNotification", + "AppleInterfaceThemeChangedNotification", + ] { + let async_cx = async_cx.clone(); + let block = block2::RcBlock::new(move |_notification: NonNull| { + async_cx.update(crate::reapply); + }); + let name = NSNotificationName::from_str(name); + // SAFETY: the observer token is retained for the source's lifetime, + // the block is sendable (AsyncApp is Clone + 'static) + // and the main queue runs it outside any App borrow. + let observer = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(&name), + None, + Some(&queue), + &block, + ) + }; + self._observers.push(observer); + } + } + + fn label(&self) -> &'static str { + SourceKind::Macos.label() + } +} diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs new file mode 100644 index 0000000..f27a336 --- /dev/null +++ b/crates/theme/src/system.rs @@ -0,0 +1,72 @@ +//! The normalized snapshot a platform adapter produces. + +use gpui::Hsla; + +use crate::ThemeVariant; + +/// Accessibility preferences read from the OS, forwarded into resolution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct AccessibilityPreferences { + pub high_contrast: bool, + pub reduce_motion: bool, + pub reduce_transparency: bool, +} + +/// Which adapter produced a [`SystemTheme`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SourceKind { + #[default] + Fallback, + Macos, +} + +impl SourceKind { + pub fn label(self) -> &'static str { + match self { + Self::Fallback => "neutral fallback", + Self::Macos => "macOS (NSColor)", + } + } +} + +/// A partial palette with optional colors. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PartialPalette { + pub background: Option, + pub foreground: Option, + pub surface: Option, + pub surface_foreground: Option, + pub primary_foreground: Option, + pub accent_foreground: Option, + pub secondary: Option, + pub secondary_foreground: Option, + pub muted: Option, + pub muted_foreground: Option, + pub destructive: Option, + pub border: Option, + pub input: Option, + pub ring: Option, + pub selection: Option, +} + +/// A normalized snapshot of the running system's theme. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SystemTheme { + pub variant: ThemeVariant, + pub accent: Option, + pub palette: PartialPalette, + pub accessibility: AccessibilityPreferences, + pub source: SourceKind, +} + +impl SystemTheme { + pub fn fallback(variant: ThemeVariant) -> Self { + Self { + variant, + accent: None, + palette: PartialPalette::default(), + accessibility: AccessibilityPreferences::default(), + source: SourceKind::Fallback, + } + } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 42a9af1..12aac8b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -196,7 +196,7 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te | Phase | Scope | Acceptance | | --- | --- | --- | | A — scaffold (next) | Workspace per §4.1: theme stub, icons crate (`IconRole` + Lucide fallback), core crate with feature scaffolding (zero components), facade, gallery shell, CI incl. feature checks | `gallery` opens a window reading the theme global; `core` builds at default / no-default / all / each-feature-alone | -| B — theme engine core | Token model + resolve + presets (neutral fallback, tokyo-night) + macOS source + `ThemeWatcher`; gallery theme inspector | Button + Checkbox painted from live macOS system tokens; flipping system appearance repaints live | +| B — theme engine core | Token model + resolve + presets (neutral fallback, tokyo-night) + macOS source + `ThemeWatcher`; gallery theme inspector. Done: `resolve` overlay chain (base → system → preset → mode override → high-contrast) is pure and tested; OKLCH accent-ramp engine; `NSColor`/`NSWorkspace` adapter; distributed-notification watchers; inspector with live mode/preset switching. (The "Button + Checkbox painted from live tokens" proof moves to Phase C — those components land there; the gallery inspector proves the live pipeline meanwhile.) | | C — controls | Wave 1 in `core`, one feature each; macOS SF Symbols resolver behind the icon seam | Each control: tests + gallery page + native-reference screenshots; icon browser shows per-role resolution | | D — input | Wave 2 | Single-line input with cursor, selection, clipboard; NumberInput | | E — overlays | Wave 3 | Focus trapping and escape/dismiss semantics per overlay | @@ -254,6 +254,6 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions). D10 (no icon bundling) is s ## 12. Immediate next steps -1. ~~Phase A scaffold~~ ✅ — workspace, toolchain pin, CI, theme seed (neutral light/dark presets + `Theme` global + appearance sync), icons crate (`IconRole` + Lucide fallback + `IconAssets`), core feature scaffolding, facade, gallery shell. -2. Phase B theme engine core, macOS adapter first. -3. Then wave 1 controls. +1. ~~Phase A scaffold~~ ✅ — workspace, toolchain pin, CI, theme seed, icons crate, core feature scaffolding, facade, gallery shell. +2. ~~Phase B theme engine core~~ ✅ — resolve pipeline, OKLCH ramps, presets, macOS source + watchers, gallery theme inspector. +3. Then wave 1 controls (Phase C). diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index fc496a9..a359c20 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -1,6 +1,8 @@ +use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, Context, IntoElement, ParentElement as _, Render, Styled as _, - Window, WindowBounds, WindowOptions, div, px, size, + App, AppContext as _, Bounds, ClickEvent, Context, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, + WindowBounds, WindowOptions, div, px, size, }; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::Theme; @@ -13,6 +15,9 @@ impl Render for Gallery { let theme = Theme::global(cx); let colors = theme.colors; let variant = theme.variant; + let source = gpui_native_theme::detected_source(cx); + let mode_override = gpui_native_theme::mode_override(cx); + let active_preset = gpui_native_theme::active_preset(cx); let swatches: [(&str, gpui::Hsla); 18] = [ ("background", colors.background), @@ -34,6 +39,34 @@ impl Render for Gallery { ("ring", colors.ring), ("selection", colors.selection), ]; + let shades = [ + ("base", theme.ramps.primary.base), + ("hover", theme.ramps.primary.hover), + ("pressed", theme.ramps.primary.pressed), + ("subtle", theme.ramps.primary.subtle), + ]; + + let modes: [(SharedString, Option, bool); 3] = [ + ("System".into(), None, mode_override.is_none()), + ( + "Light".into(), + Some(gpui_native_theme::ThemeVariant::Light), + mode_override == Some(gpui_native_theme::ThemeVariant::Light), + ), + ( + "Dark".into(), + Some(gpui_native_theme::ThemeVariant::Dark), + mode_override == Some(gpui_native_theme::ThemeVariant::Dark), + ), + ]; + let presets: [(SharedString, Option<&'static str>, bool); 2] = [ + ("Follow system".into(), None, active_preset.is_none()), + ( + "Tokyo Night".into(), + Some("tokyo-night"), + active_preset == Some("tokyo-night"), + ), + ]; div() .size_full() @@ -60,7 +93,7 @@ impl Render for Gallery { .text_sm() .text_color(colors.muted_foreground) .child(format!( - "phase A scaffold — variant: {variant:?} (system-resolved)" + "phase B — theme engine · source: {source} · variant: {variant:?}" )), ), ) @@ -69,13 +102,78 @@ impl Render for Gallery { .flex() .flex_col() .gap_2() + .child(section_label("mode", colors)) .child( div() - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(colors.muted_foreground) - .child("color tokens"), - ) + .flex() + .gap_2() + .children(modes.map(|(label, value, active)| { + chip( + format!("mode-{label}"), + label, + active, + colors, + move |_, _, cx| gpui_native_theme::set_mode_override(value, cx), + ) + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label("preset", colors)) + .child( + div() + .flex() + .gap_2() + .children(presets.map(|(label, value, active)| { + chip( + format!("preset-{label}"), + label, + active, + colors, + move |_, _, cx| { + let _ = gpui_native_theme::set_preset(value, cx); + }, + ) + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label("accent shades (derived, OKLCH)", colors)) + .child(div().flex().gap_2().children(shades.map(|(name, color)| { + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .size_6() + .rounded_full() + .border_1() + .border_color(colors.border) + .bg(color), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(name), + ) + }))), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label("color tokens", colors)) .child(div().flex().flex_wrap().gap_2().children(swatches.map( |(name, color)| { div() @@ -104,13 +202,10 @@ impl Render for Gallery { .flex() .flex_col() .gap_2() - .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(colors.muted_foreground) - .child("icons (Lucide fallback; platform resolvers land later)"), - ) + .child(section_label( + "icons (Lucide fallback; platform resolvers land later)", + colors, + )) .child( div().flex().items_center().gap_4().children( IconRole::all() @@ -122,10 +217,51 @@ impl Render for Gallery { } } +fn section_label(text: &str, colors: gpui_native_theme::ColorTokens) -> impl IntoElement + use<> { + div() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(colors.muted_foreground) + .child(text.to_string()) +} + +fn chip( + id: String, + label: SharedString, + active: bool, + colors: gpui_native_theme::ColorTokens, + on_click: F, +) -> impl IntoElement + use +where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, +{ + div() + .id(SharedString::from(id)) + .px_3() + .py_1() + .rounded_full() + .border_1() + .text_xs() + .cursor_pointer() + .when(active, |el| { + el.bg(colors.primary) + .border_color(colors.primary) + .text_color(colors.primary_foreground) + }) + .when(!active, |el| { + el.bg(colors.secondary) + .border_color(colors.border) + .text_color(colors.secondary_foreground) + .hover(|el| el.bg(colors.muted)) + }) + .on_click(move |event, window, cx| on_click(event, window, cx)) + .child(label) +} + fn main() { application().with_assets(IconAssets).run(|cx: &mut App| { Theme::init(cx); - let bounds = Bounds::centered(None, size(px(720.0), px(480.0)), cx); + let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)),