//! The theme engine: reads the running system theme, resolves it through //! presets and overrides, and installs the tokens every component paints //! from. //! //! 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, Hsla, Pixels, WindowAppearance, px}; use std::time::Duration; /// Light or dark, resolved from the system appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThemeVariant { #[default] Light, Dark, } impl ThemeVariant { fn from_appearance(appearance: WindowAppearance) -> Self { match appearance { WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light, WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark, } } } /// The resolved theme, installed as the app-wide global. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Theme { pub variant: ThemeVariant, pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, pub elevations: Elevations, /// Overlay transition timing; gpui's animation primitives do the rest. pub motion: Motion, /// Overlay scrollbar geometry. pub scrollbar: Scrollbar, /// The height the window's titlebar strips draw at, per the platform. pub titlebar_height: Pixels, /// The platform's native root font size; rem-based sizing scales with it. pub root_font_size: Pixels, pub sizes: SizeTable, pub accessibility: AccessibilityPreferences, } impl Global for Theme {} /// Corner radii components paint from; grows one class at a time. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Radius { /// Small controls: checkbox and radio indicators. pub sm: Pixels, pub control: Pixels, /// Floating layers: popovers, menus, dialogs. pub overlay: Pixels, /// Pill and circle shapes. pub full: Pixels, } impl Default for Radius { fn default() -> Self { Self { sm: px(4.), control: px(6.), overlay: px(10.), full: px(9999.), } } } /// One layer of a floating panel's drop shadow. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Shadow { pub offset_x: Pixels, pub offset_y: Pixels, pub blur_radius: Pixels, pub spread_radius: Pixels, /// Translucent black, like every native overlay shadow. pub color: Hsla, } impl Shadow { fn none() -> Self { Self { offset_x: px(0.), offset_y: px(0.), blur_radius: px(0.), spread_radius: px(0.), color: colors::scrim(0.), } } fn black(offset_x: f32, offset_y: f32, blur_radius: f32, alpha: f32) -> Self { Self { offset_x: px(offset_x), offset_y: px(offset_y), blur_radius: px(blur_radius), spread_radius: px(0.), color: colors::scrim(alpha), } } } /// The drop shadow stack of a floating layer. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Elevation { /// Key shadow first, ambient grounding second. pub layers: [Shadow; 2], } impl Elevation { fn single(key: Shadow) -> Self { Self { layers: [key, Shadow::none()], } } fn layered(key: Shadow, ambient: Shadow) -> Self { Self { layers: [key, ambient], } } /// The gpui shadow list an overlay panel paints with. pub fn shadows(self) -> Vec { self.layers .iter() .filter(|layer| layer.color.a > 0.) .map(|layer| gpui::BoxShadow { color: layer.color, offset: gpui::point(layer.offset_x, layer.offset_y), blur_radius: layer.blur_radius, spread_radius: layer.spread_radius, inset: false, }) .collect() } } /// The drop shadows of each floating layer class. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Elevations { /// Popovers, menus, and pushed alerts ride above content. pub popover: Elevation, /// Modal dialogs sit higher with a wider one. pub dialog: Elevation, /// Raised plates inside controls — a selected segmented segment. pub control: Elevation, } impl Elevations { pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self { if variant == ThemeVariant::Dark { return Self { popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)), dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)), control: Elevation::single(Shadow::black(0., 1., 2., 0.25)), }; } match source { SourceKind::Macos => Self { popover: Elevation::layered( Shadow::black(0., 4., 24., 0.12), Shadow::black(0., 1., 1., 0.05), ), dialog: Elevation::layered( Shadow::black(0., 8., 40., 0.14), Shadow::black(0., 1., 2., 0.06), ), control: Elevation::single(Shadow::black(0., 1., 2., 0.1)), }, SourceKind::Windows => Self { popover: Elevation::layered( Shadow::black(0., 8., 16., 0.14), Shadow::black(0., 2., 4., 0.1), ), dialog: Elevation::layered( Shadow::black(0., 16., 32., 0.16), Shadow::black(0., 4., 8., 0.1), ), control: Elevation::single(Shadow::black(0., 1., 2., 0.12)), }, SourceKind::Omarchy | SourceKind::Kde | SourceKind::Gnome | SourceKind::Fallback => { Self { popover: Elevation::single(Shadow::black(0., 3., 6., 0.2)), dialog: Elevation::single(Shadow::black(0., 9., 18., 0.24)), control: Elevation::single(Shadow::black(0., 1., 2., 0.16)), } } } } } /// Overlay transition timings; the platform refines these per phase. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Motion { /// The enter transition of popovers, menus, and dialogs. pub overlay: Duration, } impl Default for Motion { fn default() -> Self { Self { overlay: Duration::from_millis(200), } } } /// Overlay scrollbar geometry, transcribed from the platform's overlay crollbars. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Scrollbar { /// The painted thumb's thickness. pub width: Pixels, /// Edge padding between the thumb and the viewport it floats over. pub inset: Pixels, /// The thumb never shrinks below this length, so it stays grabbable. pub min_thumb: Pixels, } impl Default for Scrollbar { fn default() -> Self { Self { width: px(8.), inset: px(2.), min_thumb: px(24.), } } } /// A named control size class. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Size { Small, /// The default. #[default] Medium, Large, } /// The control metrics for one size class. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ControlMetrics { /// Push buttons, toggles, and field rows. pub control_height: Pixels, /// Text fields; slightly taller than buttons, as native fields are. pub input_height: Pixels, /// Horizontal padding inside controls. pub padding_x: Pixels, /// Label text size. pub text_size: Pixels, /// Icons and on/off indicators. pub icon_size: Pixels, /// Icons inline with dense content — menu rows, badges, chevrons. pub dense_icon_size: Pixels, /// Selection indicators — checkboxes, radios, and switch knobs. pub indicator_size: Pixels, /// Avatars. pub avatar_size: Pixels, /// Dense label text. pub caption_size: Pixels, /// Tight horizontal padding inside pills, segments, and fields. pub dense_padding_x: Pixels, /// The inset between a control's edge and a raised inner plate. pub plate_inset: Pixels, /// The band height of multi-segment strips. pub strip_height: Pixels, } /// The control metrics for every size class. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SizeTable { pub small: ControlMetrics, pub medium: ControlMetrics, pub large: ControlMetrics, } impl SizeTable { pub fn with_root_font_size(rem_size: Pixels) -> Self { struct ClassFactors { height: f32, padding: f32, text: f32, icon: f32, avatar: f32, strip: f32, inset: f32, } let small = ClassFactors { height: 1.5, padding: 0.75, text: 0.85, icon: 0.875, avatar: 1.5, strip: 1.5, inset: 0.125, }; let medium = ClassFactors { height: 1.75, padding: 1.0, text: 1.0, icon: 1.0, avatar: 2.0, strip: 2.0, inset: 0.25, }; let large = ClassFactors { height: 2.25, padding: 1.25, text: 1.15, icon: 1.25, avatar: 3.0, strip: 3.0, inset: 0.375, }; let metrics = |f: &ClassFactors| ControlMetrics { control_height: rem_size * f.height, input_height: rem_size * (f.height + 0.125), padding_x: rem_size * f.padding, text_size: rem_size * f.text, icon_size: rem_size * f.icon, dense_icon_size: rem_size * f.icon * 0.75, indicator_size: rem_size * f.height * 0.7, avatar_size: rem_size * f.avatar, caption_size: rem_size * f.text, dense_padding_x: rem_size * f.padding * 0.75, plate_inset: rem_size * f.inset, strip_height: rem_size * f.strip, }; let mut table = Self { small: metrics(&small), medium: metrics(&medium), large: metrics(&large), }; table.small.caption_size = table.small.text_size * 0.85; table.large.caption_size = table.medium.text_size; table } /// The metrics for `size`. pub fn resolve(&self, size: Size) -> ControlMetrics { match size { Size::Small => self.small, Size::Medium => self.medium, Size::Large => self.large, } } /// Sets the medium class's text-field height to the platform's stated value. pub fn set_input_height(&mut self, medium_input_height: Pixels) { let ratio = medium_input_height / self.medium.input_height; self.small.input_height *= ratio; self.medium.input_height = medium_input_height; self.large.input_height *= ratio; } } /// Reads the resolved theme global from any app context. pub trait ActiveTheme { fn theme(&self) -> &Theme; } impl ActiveTheme for App { fn theme(&self) -> &Theme { Theme::global(self) } } impl Theme { /// Reads the theme global. Panics before `init`. pub fn global(cx: &App) -> &Self { cx.global::() } fn from_resolved(resolved: ResolvedTheme) -> Self { Self { variant: resolved.variant, colors: resolved.colors, ramps: resolved.ramps, radius: resolved.radius, elevations: resolved.elevations, motion: resolved.motion, scrollbar: resolved.scrollbar, titlebar_height: resolved.titlebar_height, root_font_size: resolved.root_font_size, sizes: resolved.sizes, accessibility: resolved.accessibility, } } /// Installs the engine global, its platform watchers, and the initial resolved theme. pub fn init(cx: &mut App) { 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-resolves when a window's appearance differs from the resolved variant. /// /// Wire to `Window::observe_window_appearance`. pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) { let variant = ThemeVariant::from_appearance(window.appearance()); if cx.global::().variant != variant || cx.global::().mode_override.is_none() { reapply(cx); } } /// Sizes the window's rem to the theme's root font size. pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) { window.set_rem_size(cx.global::().root_font_size); } } /// The engine's knobs and its platform source. struct ThemeEngine { source: Box, mode_override: Option, preset: Option<(&'static str, ThemeDefinition)>, } impl Global for ThemeEngine {} /// Reads the system, resolves through the current knobs, /// installs the theme global, and repaints every window. pub(crate) fn reapply(cx: &mut App) { let (system_appearance, preset, mode_override) = { let engine = cx.global::(); ( ThemeVariant::from_appearance(cx.window_appearance()), engine.preset.as_ref().map(|(_, def)| def.clone()), engine.mode_override, ) }; let variant = mode_override .or(preset.as_ref().and_then(|def| def.mode)) .unwrap_or(system_appearance); let system = cx.global::().source.snapshot(variant); let resolved = resolve::resolve(&system, preset.as_ref(), mode_override); cx.set_reduce_motion(resolved.accessibility.reduce_motion); cx.set_global(Theme::from_resolved(resolved)); cx.refresh_windows(); } /// Forces a variant, independent of the OS. `None` follows the system. pub fn set_mode_override(variant: Option, cx: &mut App) { cx.global_mut::().mode_override = variant; reapply(cx); } /// Installs a built-in preset, or `None` to follow the system again. pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> { let preset = match name { Some(name) => { let def = presets::builtin(name).ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?; let static_name = presets::preset_names() .iter() .copied() .find(|candidate| *candidate == name) .ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?; Some((static_name, def)) } None => None, }; cx.global_mut::().preset = preset; reapply(cx); Ok(()) } /// The variant the mode override forces, if any. pub fn mode_override(cx: &App) -> Option { cx.global::().mode_override } /// The active preset name, if one is installed. pub fn active_preset(cx: &App) -> Option<&'static str> { cx.global::() .preset .as_ref() .map(|(name, _)| *name) } /// Clears the mode override and preset; the theme follows the system again. pub fn follow_system(cx: &mut App) { let engine = cx.global_mut::(); engine.mode_override = None; engine.preset = None; reapply(cx); } /// Which adapter produced the current system snapshot. pub fn detected_source(cx: &App) -> &'static str { cx.global::().source.label() }