theme: build out the theme engine core with resolve pipeline
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user