theme: build out the theme engine core with resolve pipeline

This commit is contained in:
2026-10-28 10:54:02 +07:00
parent b4d3359d4f
commit 12d4120bfd
14 changed files with 1434 additions and 37 deletions
Generated
+7
View File
@@ -1614,7 +1614,14 @@ dependencies = [
name = "gpui-native-theme" name = "gpui-native-theme"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"block2 0.6.2",
"gpui", "gpui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
"toml 0.8.23",
] ]
[[package]] [[package]]
+24
View File
@@ -5,4 +5,28 @@ edition = "2024"
description = "Native theme engine: reads the OS theme and resolves it into semantic tokens" description = "Native theme engine: reads the OS theme and resolves it into semantic tokens"
[dependencies] [dependencies]
anyhow = "1"
gpui = { workspace = true } 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",
] }
+21
View File
@@ -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"
+190
View File
@@ -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<ThemeVariant>,
pub accent: Option<String>,
pub accent_foreground: Option<String>,
pub background: Option<String>,
pub foreground: Option<String>,
pub surface: Option<String>,
pub surface_foreground: Option<String>,
pub primary: Option<String>,
pub primary_foreground: Option<String>,
pub secondary: Option<String>,
pub secondary_foreground: Option<String>,
pub muted: Option<String>,
pub muted_foreground: Option<String>,
pub destructive: Option<String>,
pub destructive_foreground: Option<String>,
pub border: Option<String>,
pub input: Option<String>,
pub ring: Option<String>,
pub selection: Option<String>,
}
impl ThemeDefinition {
pub fn parse_toml(source: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(source)?)
}
pub fn accent(&self) -> Option<Hsla> {
parse_hex(self.accent.as_deref()?)
}
pub fn accent_foreground(&self) -> Option<Hsla> {
parse_hex(self.accent_foreground.as_deref()?)
}
pub fn background(&self) -> Option<Hsla> {
parse_hex(self.background.as_deref()?)
}
pub fn foreground(&self) -> Option<Hsla> {
parse_hex(self.foreground.as_deref()?)
}
pub fn surface(&self) -> Option<Hsla> {
parse_hex(self.surface.as_deref()?)
}
pub fn surface_foreground(&self) -> Option<Hsla> {
parse_hex(self.surface_foreground.as_deref()?)
}
pub fn primary(&self) -> Option<Hsla> {
parse_hex(self.primary.as_deref()?)
}
pub fn primary_foreground(&self) -> Option<Hsla> {
parse_hex(self.primary_foreground.as_deref()?)
}
pub fn secondary(&self) -> Option<Hsla> {
parse_hex(self.secondary.as_deref()?)
}
pub fn secondary_foreground(&self) -> Option<Hsla> {
parse_hex(self.secondary_foreground.as_deref()?)
}
pub fn muted(&self) -> Option<Hsla> {
parse_hex(self.muted.as_deref()?)
}
pub fn muted_foreground(&self) -> Option<Hsla> {
parse_hex(self.muted_foreground.as_deref()?)
}
pub fn destructive(&self) -> Option<Hsla> {
parse_hex(self.destructive.as_deref()?)
}
pub fn destructive_foreground(&self) -> Option<Hsla> {
parse_hex(self.destructive_foreground.as_deref()?)
}
pub fn border(&self) -> Option<Hsla> {
parse_hex(self.border.as_deref()?)
}
pub fn input(&self) -> Option<Hsla> {
parse_hex(self.input.as_deref()?)
}
pub fn ring(&self) -> Option<Hsla> {
parse_hex(self.ring.as_deref()?)
}
pub fn selection(&self) -> Option<Hsla> {
parse_hex(self.selection.as_deref()?)
}
}
/// Parses `#rgb` or `#rrggbb` into an opaque color.
pub(crate) fn parse_hex(value: &str) -> Option<Hsla> {
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());
}
}
+125 -17
View File
@@ -1,17 +1,30 @@
//! The theme engine: reads the running system theme and resolves it into the //! The theme engine: reads the running system theme, resolves it through
//! semantic tokens every component paints from. //! presets and overrides, and installs the tokens every component paints
//! from.
//! //!
//! Phase B replaces the bundled light/dark presets with platform adapters //! Pipeline (plan §4.5): `ThemeSource` snapshot → `resolve` overlay chain →
//! (`NSColor` / `UISettings` / portal / Omarchy) behind a `ThemeSource` seam. //! `Theme` global → all windows repaint. Watchers re-run the pipeline on
//! real OS change events.
mod colors; mod colors;
mod definition;
mod presets;
mod ramps;
mod resolve;
mod source;
mod system;
pub use colors::ColorTokens; 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}; use gpui::{App, Global, WindowAppearance};
/// Light or dark, resolved from the system appearance. /// 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 { pub enum ThemeVariant {
#[default] #[default]
Light, Light,
@@ -32,6 +45,8 @@ impl ThemeVariant {
pub struct Theme { pub struct Theme {
pub variant: ThemeVariant, pub variant: ThemeVariant,
pub colors: ColorTokens, pub colors: ColorTokens,
pub ramps: Ramps,
pub accessibility: AccessibilityPreferences,
} }
impl Global for Theme {} impl Global for Theme {}
@@ -42,27 +57,120 @@ impl Theme {
cx.global::<Self>() cx.global::<Self>()
} }
pub fn for_variant(variant: ThemeVariant) -> Self { fn from_resolved(resolved: ResolvedTheme) -> Self {
Self { Self {
variant, variant: resolved.variant,
colors: match variant { colors: resolved.colors,
ThemeVariant::Light => ColorTokens::light(), ramps: resolved.ramps,
ThemeVariant::Dark => ColorTokens::dark(), 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) { pub fn init(cx: &mut App) {
let variant = ThemeVariant::from_appearance(cx.window_appearance()); let mut engine = ThemeEngine {
cx.set_global(Self::for_variant(variant)); 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) { pub fn sync_from_window(window: &gpui::Window, cx: &mut App) {
let variant = ThemeVariant::from_appearance(window.appearance()); let variant = ThemeVariant::from_appearance(window.appearance());
if cx.global::<Self>().variant != variant { if cx.global::<Theme>().variant != variant
cx.set_global(Self::for_variant(variant)); || cx.global::<ThemeEngine>().mode_override.is_none()
{
reapply(cx);
} }
} }
} }
/// The engine's knobs and its platform source.
struct ThemeEngine {
source: Box<dyn source::ThemeSource>,
mode_override: Option<ThemeVariant>,
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::<ThemeEngine>();
(
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::<ThemeEngine>().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<ThemeVariant>, cx: &mut App) {
cx.global_mut::<ThemeEngine>().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::<ThemeEngine>().preset = preset;
reapply(cx);
Ok(())
}
/// The variant the mode override forces, if any.
pub fn mode_override(cx: &App) -> Option<ThemeVariant> {
cx.global::<ThemeEngine>().mode_override
}
/// The active preset name, if one is installed.
pub fn active_preset(cx: &App) -> Option<&'static str> {
cx.global::<ThemeEngine>()
.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::<ThemeEngine>();
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::<ThemeEngine>().source.label()
}
+23
View File
@@ -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<ThemeDefinition> {
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,
}
}
+273
View File
@@ -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
);
}
}
+356
View File
@@ -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<ThemeVariant>,
) -> 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<gpui::Hsla>, system: Option<gpui::Hsla>, base: gpui::Hsla| {
def.or(system).unwrap_or(base)
};
let def_color = |field: fn(&ThemeDefinition) -> Option<gpui::Hsla>| 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<gpui::Hsla>,
) -> 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);
}
}
}
+28
View File
@@ -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<dyn ThemeSource> {
Box::new(macos::MacosSource::default())
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
Box::new(fallback::FallbackSource)
}
#[cfg(target_os = "macos")]
pub(crate) mod macos;
pub(crate) mod fallback;
+20
View File
@@ -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()
}
}
+139
View File
@@ -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<Retained<ProtocolObject<dyn NSObjectProtocol>>>,
}
impl MacosSource {
fn color_to_hsla(color: &NSColor) -> Option<gpui::Hsla> {
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<NSNotification>| {
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()
}
}
+72
View File
@@ -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<Hsla>,
pub foreground: Option<Hsla>,
pub surface: Option<Hsla>,
pub surface_foreground: Option<Hsla>,
pub primary_foreground: Option<Hsla>,
pub accent_foreground: Option<Hsla>,
pub secondary: Option<Hsla>,
pub secondary_foreground: Option<Hsla>,
pub muted: Option<Hsla>,
pub muted_foreground: Option<Hsla>,
pub destructive: Option<Hsla>,
pub border: Option<Hsla>,
pub input: Option<Hsla>,
pub ring: Option<Hsla>,
pub selection: Option<Hsla>,
}
/// A normalized snapshot of the running system's theme.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SystemTheme {
pub variant: ThemeVariant,
pub accent: Option<Hsla>,
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,
}
}
}
+4 -4
View File
@@ -196,7 +196,7 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te
| Phase | Scope | Acceptance | | 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 | | 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 | | 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 | | D — input | Wave 2 | Single-line input with cursor, selection, clipboard; NumberInput |
| E — overlays | Wave 3 | Focus trapping and escape/dismiss semantics per overlay | | 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 ## 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. 1. ~~Phase A scaffold~~ ✅ — workspace, toolchain pin, CI, theme seed, icons crate, core feature scaffolding, facade, gallery shell.
2. Phase B theme engine core, macOS adapter first. 2. ~~Phase B theme engine core~~ ✅ — resolve pipeline, OKLCH ramps, presets, macOS source + watchers, gallery theme inspector.
3. Then wave 1 controls. 3. Then wave 1 controls (Phase C).
+151 -15
View File
@@ -1,6 +1,8 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, AppContext as _, Bounds, Context, IntoElement, ParentElement as _, Render, Styled as _, App, AppContext as _, Bounds, ClickEvent, Context, InteractiveElement as _, IntoElement,
Window, WindowBounds, WindowOptions, div, px, size, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window,
WindowBounds, WindowOptions, div, px, size,
}; };
use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_icons::{IconAssets, IconRole, icon};
use gpui_native_theme::Theme; use gpui_native_theme::Theme;
@@ -13,6 +15,9 @@ impl Render for Gallery {
let theme = Theme::global(cx); let theme = Theme::global(cx);
let colors = theme.colors; let colors = theme.colors;
let variant = theme.variant; 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] = [ let swatches: [(&str, gpui::Hsla); 18] = [
("background", colors.background), ("background", colors.background),
@@ -34,6 +39,34 @@ impl Render for Gallery {
("ring", colors.ring), ("ring", colors.ring),
("selection", colors.selection), ("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<gpui_native_theme::ThemeVariant>, 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() div()
.size_full() .size_full()
@@ -60,7 +93,7 @@ impl Render for Gallery {
.text_sm() .text_sm()
.text_color(colors.muted_foreground) .text_color(colors.muted_foreground)
.child(format!( .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()
.flex_col() .flex_col()
.gap_2() .gap_2()
.child(section_label("mode", colors))
.child( .child(
div() div()
.text_sm() .flex()
.font_weight(gpui::FontWeight::MEDIUM) .gap_2()
.text_color(colors.muted_foreground) .children(modes.map(|(label, value, active)| {
.child("color tokens"), 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( .child(div().flex().flex_wrap().gap_2().children(swatches.map(
|(name, color)| { |(name, color)| {
div() div()
@@ -104,13 +202,10 @@ impl Render for Gallery {
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
.child( .child(section_label(
div() "icons (Lucide fallback; platform resolvers land later)",
.text_sm() colors,
.font_weight(gpui::FontWeight::MEDIUM) ))
.text_color(colors.muted_foreground)
.child("icons (Lucide fallback; platform resolvers land later)"),
)
.child( .child(
div().flex().items_center().gap_4().children( div().flex().items_center().gap_4().children(
IconRole::all() 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<F>(
id: String,
label: SharedString,
active: bool,
colors: gpui_native_theme::ColorTokens,
on_click: F,
) -> impl IntoElement + use<F>
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() { fn main() {
application().with_assets(IconAssets).run(|cx: &mut App| { application().with_assets(IconAssets).run(|cx: &mut App| {
Theme::init(cx); 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( if let Err(err) = cx.open_window(
WindowOptions { WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)), window_bounds: Some(WindowBounds::Windowed(bounds)),