529 lines
16 KiB
Rust
529 lines
16 KiB
Rust
//! 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<gpui::BoxShadow> {
|
|
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::<Self>()
|
|
}
|
|
|
|
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::<Theme>().variant != variant
|
|
|| cx.global::<ThemeEngine>().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::<Theme>().root_font_size);
|
|
}
|
|
}
|
|
|
|
/// 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_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<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()
|
|
}
|