use crate::definition::ThemeDefinition; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; use crate::system::SystemTheme; use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant}; use gpui::px; /// Derived state shades for the colors that get pressed and hovered. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Ramps { pub primary: AccentShades, pub secondary: AccentShades, pub destructive: AccentShades, pub success: AccentShades, pub warning: AccentShades, pub info: 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 radius: Radius, pub elevations: Elevations, pub motion: Motion, pub scrollbar: Scrollbar, /// The platform's native root font size; the fallback theme uses 16. pub root_font_size: gpui::Pixels, pub sizes: SizeTable, pub accessibility: crate::system::AccessibilityPreferences, } /// Overlay order (later wins): neutral base → system → preset definition. pub(crate) fn resolve( system: &SystemTheme, preset: Option<&ThemeDefinition>, mode_override: Option, ) -> ResolvedTheme { let variant = mode_override .or(preset.and_then(|def| def.mode)) .unwrap_or(system.variant); let base = match variant { ThemeVariant::Light => ColorTokens::light(), ThemeVariant::Dark => ColorTokens::dark(), }; let system_palette = system.palette; let pick = |def: Option, system: Option, base: gpui::Hsla| { def.or(system).unwrap_or(base) }; let def_color = |field: fn(&ThemeDefinition) -> Option| preset.and_then(field); let accent = pick(def_color(|d| d.accent()), system.accent, base.accent); let primary = pick(def_color(|d| d.primary()), system.accent, base.primary); let destructive = pick( def_color(|d| d.destructive()), system_palette.destructive, base.destructive, ); let success = pick( def_color(|d| d.success()), system_palette.success, base.success, ); let warning = pick( def_color(|d| d.warning()), system_palette.warning, base.warning, ); let info = pick(def_color(|d| d.info()), system_palette.info, base.info); 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), ), success, success_foreground: pick( def_color(|d| d.success_foreground()), None, contrast_foreground(success), ), warning, warning_foreground: pick( def_color(|d| d.warning_foreground()), None, contrast_foreground(warning), ), info, info_foreground: pick( def_color(|d| d.info_foreground()), None, contrast_foreground(info), ), 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, ), overlay: pick( def_color(|d| d.overlay()), system_palette.overlay, base.overlay, ), scrollbar_thumb: pick( def_color(|d| d.scrollbar_thumb()), system_palette.scrollbar_thumb, base.scrollbar_thumb, ), }; 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(); } // The platform's root font size scales every control metric: // a medium control's label is exactly the root font size. // // Fallback: 16 (the web default gpui also uses for rems). let root_font_size = system.root_font_size.unwrap_or(px(16.)); let mut sizes = SizeTable::with_root_font_size(root_font_size); if let Some(input_height) = system.input_height { sizes.set_input_height(input_height); } let mut radius = Radius::default(); if let Some(control) = preset .and_then(|def| def.control_radius) .map(px) .or(system.control_radius) { radius.control = control; } ResolvedTheme { variant, ramps: Ramps { primary: derive_shades(primary, colors.background, variant == ThemeVariant::Dark), secondary: derive_shades( colors.secondary, colors.background, variant == ThemeVariant::Dark, ), destructive: derive_shades( destructive, colors.background, variant == ThemeVariant::Dark, ), success: derive_shades(success, colors.background, variant == ThemeVariant::Dark), warning: derive_shades(warning, colors.background, variant == ThemeVariant::Dark), info: derive_shades(info, colors.background, variant == ThemeVariant::Dark), }, colors, radius, elevations: Elevations::for_platform(system.source, variant), motion: Motion::default(), scrollbar: Scrollbar::default(), root_font_size, sizes, accessibility, } } #[cfg(test)] mod tests { use super::*; use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind}; use gpui::{Rgba, rgb}; fn hsla(hex: u32) -> gpui::Hsla { gpui::Hsla::from(rgb(hex)) } fn system( variant: ThemeVariant, palette: PartialPalette, accent: Option, ) -> SystemTheme { SystemTheme { variant, accent, palette, control_radius: None, input_height: None, root_font_size: None, accessibility: AccessibilityPreferences::default(), source: SourceKind::Macos, } } #[test] fn metric_priority_is_definition_then_system_then_fallback() { let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None); stated.control_radius = Some(px(4.)); let resolved = resolve(&stated, None, None); assert_eq!(resolved.radius.control, px(4.)); let def = ThemeDefinition::parse_toml("control_radius = 12.0\n").unwrap(); let overridden = resolve(&stated, Some(&def), None); assert_eq!(overridden.radius.control, px(12.)); } #[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); assert_eq!(resolved.ramps.secondary.base, base.secondary); assert_eq!(resolved.radius.sm, px(4.)); assert_eq!(resolved.radius.control, px(6.)); assert_eq!(resolved.root_font_size, px(16.)); assert_eq!(resolved.sizes.medium.control_height, px(28.)); assert_eq!(resolved.sizes.medium.input_height, px(30.)); assert_eq!(resolved.sizes.medium.text_size, px(16.)); assert_eq!(resolved.sizes.large.avatar_size, px(48.)); } } #[test] fn a_stated_root_font_size_scales_the_size_table() { // macOS states 13pt; the medium control shrinks to native proportions. let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); stated.root_font_size = Some(px(13.)); let resolved = resolve(&stated, None, None); assert_eq!(resolved.root_font_size, px(13.)); assert_eq!(resolved.sizes.medium.control_height, px(22.75)); assert_eq!(resolved.sizes.medium.text_size, px(13.)); assert_eq!(resolved.sizes.small.text_size, px(11.05)); } #[test] fn a_stated_input_height_overrides_the_derived_one_and_scales() { let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); stated.input_height = Some(px(24.)); let resolved = resolve(&stated, None, None); // The medium class takes the stated height verbatim, and the other // classes keep their derived ratio to the medium class. assert_eq!(resolved.sizes.medium.input_height, px(24.)); // Derive what small would be without the statement, scale it by the // stated-to-derived ratio, and compare. let derived = SizeTable::with_root_font_size(px(16.)); let scale = px(24.) / derived.medium.input_height; let expected_small = derived.small.input_height * scale; assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); } #[test] fn overlay_is_translucent_and_overridable() { let light = resolve( &system(ThemeVariant::Light, PartialPalette::default(), None), None, None, ); let dark = resolve( &system(ThemeVariant::Dark, PartialPalette::default(), None), None, None, ); // Both scrims are translucent black; dark is denser for contrast. assert!(light.colors.overlay.a > 0.0 && light.colors.overlay.a < 0.5); assert!(dark.colors.overlay.a > light.colors.overlay.a); let def = ThemeDefinition::parse_toml("overlay = \"#000000\"").unwrap(); let overridden = resolve( &system(ThemeVariant::Light, PartialPalette::default(), None), Some(&def), None, ); assert_eq!(overridden.colors.overlay, hsla(0x000000)); assert_eq!(overridden.colors.overlay.a, 1.0); } #[test] fn elevations_and_motion_resolve_with_defaults() { let resolved = resolve( &system(ThemeVariant::Dark, PartialPalette::default(), None), None, None, ); // The popover shadow is softer and tighter than the dialog's. assert!( resolved.elevations.popover.layers[0].blur_radius < resolved.elevations.dialog.layers[0].blur_radius ); assert!(resolved.motion.overlay > std::time::Duration::ZERO); let shadows = resolved.elevations.popover.shadows(); assert_eq!(shadows.len(), 1); assert!(!shadows[0].inset); } #[test] fn elevations_follow_the_platform_in_light_mode_and_share_one_dark_stack() { let popover = |source| { let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); stated.source = source; resolve(&stated, None, None).elevations.popover }; // macOS paints a soft layered stack; Linux keeps a compact single one. assert_eq!(popover(SourceKind::Macos).shadows().len(), 2); assert_eq!(popover(SourceKind::Gnome).shadows().len(), 1); assert!( popover(SourceKind::Macos).layers[0].color.a < popover(SourceKind::Gnome).layers[0].color.a ); // Dark mode is one shared stack, however the adapter detected. let dark = |source| { let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None); stated.source = source; resolve(&stated, None, None).elevations }; assert_eq!(dark(SourceKind::Macos), dark(SourceKind::Windows)); assert_eq!(dark(SourceKind::Macos), dark(SourceKind::Fallback)); } #[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\"\ncontrol_radius = 12.0\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); // And the metric override beats the radius default. assert_eq!(resolved.radius.control, px(12.)); 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); } } #[test] fn info_stays_calm_when_the_accent_warms_up() { // The whole point of the info role: a warm platform accent (GNOME // red, Omarchy red themes) must not make Info toasts read as Danger. let red_accent = hsla(0xdc2626); let resolved = resolve( &system( ThemeVariant::Dark, PartialPalette::default(), Some(red_accent), ), None, None, ); assert_eq!(resolved.ramps.primary.base, red_accent); assert_ne!(resolved.colors.info, resolved.colors.destructive); assert_ne!(resolved.ramps.info.base, resolved.ramps.destructive.base); } #[test] fn info_follows_the_severity_overlay_chain() { let palette = PartialPalette { info: Some(hsla(0x155e75)), ..PartialPalette::default() }; let system_stated = resolve(&system(ThemeVariant::Dark, palette, None), None, None); assert_eq!(system_stated.colors.info, hsla(0x155e75)); assert_eq!(system_stated.ramps.info.base, hsla(0x155e75)); let def = ThemeDefinition::parse_toml("info = \"#0ea5e9\"\n").unwrap(); let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None); assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9)); } }