theme: build out the theme engine core with resolve pipeline
This commit is contained in:
Generated
+7
@@ -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]]
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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::<Self>()
|
||||
}
|
||||
|
||||
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::<Self>().variant != variant {
|
||||
cx.set_global(Self::for_variant(variant));
|
||||
if cx.global::<Theme>().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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
+152
-16
@@ -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<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()
|
||||
.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<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() {
|
||||
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)),
|
||||
|
||||
Reference in New Issue
Block a user