Skip to main content

gpui_kit_theme/
lib.rs

1//! Maps GPUI-independent tokens into the paint and typography types views use.
2
3use std::sync::Arc;
4
5use gpui::{App, BorrowAppContext, BoxShadow, Global, Hsla, Rgba, SharedString, point, px};
6use gpui_kit_tokens::{
7    BorderWeight, Color, DensityScale, InteractiveColor, OpacityRole, TokenDocument, TokenError,
8    bundled,
9};
10
11pub use gpui_kit_tokens::{
12    Appearance, ControlSize, Density, Elevation, Layer, MotionDuration, MotionEasing, Radius,
13    SemanticColor, Space, SpringPreset, SpringTokens, Surface, TextTone, TypeScale,
14};
15
16/// Reads the active theme from any context that dereferences to [`App`].
17///
18/// Components take `&mut App` during render and pull the theme themselves, so
19/// callers never thread a `&Theme` through builder arguments.
20pub trait ActiveTheme {
21    fn theme(&self) -> &Theme;
22}
23
24impl ActiveTheme for App {
25    fn theme(&self) -> &Theme {
26        Theme::get(self)
27    }
28}
29
30#[derive(Debug, Clone)]
31pub struct Theme {
32    pub id: SharedString,
33    pub name: SharedString,
34    pub appearance: Appearance,
35    pub density: Density,
36    pub colors: Colors,
37    pub typography: Typography,
38    pub spacing: Spacing,
39    pub radii: Radii,
40    pub control: Control,
41    pub borders: Borders,
42    pub opacity: Opacity,
43    pub motion: Motion,
44    pub elevation: Elevations,
45    pub z_index: ZIndices,
46    pub effects: Effects,
47}
48
49#[derive(Debug, Clone)]
50pub struct Colors {
51    pub canvas: Hsla,
52    pub sunken: Hsla,
53    pub panel: Hsla,
54    pub raised: Hsla,
55    pub overlay: Hsla,
56    pub text: Hsla,
57    pub text_muted: Hsla,
58    pub text_faint: Hsla,
59    pub text_on_accent: Hsla,
60    pub hover: Hsla,
61    pub active: Hsla,
62    pub selected: Hsla,
63    pub hairline: Hsla,
64    pub hairline_strong: Hsla,
65    pub focus: Hsla,
66    pub accent: Hsla,
67    pub accent_strong: Hsla,
68    pub danger: Hsla,
69    pub warning: Hsla,
70    pub success: Hsla,
71    pub info: Hsla,
72    pub loader_gradient: [Hsla; 3],
73}
74
75#[derive(Debug, Clone)]
76pub struct Typography {
77    pub sans: SharedString,
78    pub sans_fallback: SharedString,
79    pub mono: SharedString,
80    pub mono_fallback: SharedString,
81    pub caption: TypeStyle,
82    pub label: TypeStyle,
83    pub body: TypeStyle,
84    pub strong: TypeStyle,
85    pub subtitle: TypeStyle,
86    pub title: TypeStyle,
87    pub code: TypeStyle,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct TypeStyle {
92    pub size: f32,
93    pub line_height: f32,
94    pub weight: f32,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Spacing {
99    pub xs: f32,
100    pub sm: f32,
101    pub md: f32,
102    pub lg: f32,
103    pub xl: f32,
104    pub xxl: f32,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct Radii {
109    pub small: f32,
110    pub control: f32,
111    pub card: f32,
112    pub dialog: f32,
113    pub bubble: f32,
114    pub pill: f32,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq)]
118pub struct Control {
119    pub xs: ControlMetrics,
120    pub sm: ControlMetrics,
121    pub md: ControlMetrics,
122    pub lg: ControlMetrics,
123}
124
125impl Control {
126    pub fn get(&self, size: ControlSize) -> ControlMetrics {
127        match size {
128            ControlSize::Xs => self.xs,
129            ControlSize::Sm => self.sm,
130            ControlSize::Md => self.md,
131            ControlSize::Lg => self.lg,
132        }
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct ControlMetrics {
138    pub height: f32,
139    pub padding_x: f32,
140    pub gap: f32,
141    pub font_size: f32,
142    pub icon_size: f32,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Borders {
147    pub hairline: f32,
148    pub thick: f32,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct Opacity {
153    pub disabled: f32,
154    pub muted: f32,
155    pub scrim: f32,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct Motion {
160    pub instant_ms: u64,
161    pub quick_ms: u64,
162    pub menu_ms: u64,
163    pub dialog_ms: u64,
164    pub resize_ms: u64,
165    pub entrance_ms: u64,
166    pub spin_ms: u64,
167    pub slow_ms: u64,
168    /// The gap between one member of a staggered group and the next.
169    pub stagger_step_ms: u64,
170    pub pulse_ms: u64,
171    pub shimmer_ms: u64,
172    pub toast_ms: u64,
173    pub linear: [f32; 4],
174    pub standard: [f32; 4],
175    pub ease_in: [f32; 4],
176    pub ease_out: [f32; 4],
177    pub ease_in_out: [f32; 4],
178    pub emphasized: [f32; 4],
179    pub overshoot: [f32; 4],
180    pub exit: [f32; 4],
181    pub settle: [f32; 4],
182    pub snappy: SpringTokens,
183    pub smooth: SpringTokens,
184    pub bouncy: SpringTokens,
185    pub grab: SpringTokens,
186    /// How far a pressed control sinks, in pixels.
187    pub press_offset: f32,
188    /// How far a hovered control rises, in pixels.
189    pub hover_lift: f32,
190    /// The speed past which a released gesture is a flick, in pixels a second.
191    pub flick_velocity: f32,
192    /// How much of an overscroll is shown at the boundary.
193    pub rubber_band_tension: f32,
194}
195
196/// Shadows for each elevation step. Flat is intentionally empty rather than a
197/// transparent shadow, so a flat surface allocates no shadow work at all.
198#[derive(Debug, Clone, PartialEq)]
199pub struct Elevations {
200    pub flat: Vec<BoxShadow>,
201    pub raised: Vec<BoxShadow>,
202    pub overlay: Vec<BoxShadow>,
203    pub modal: Vec<BoxShadow>,
204}
205
206impl Elevations {
207    pub fn get(&self, level: Elevation) -> &[BoxShadow] {
208        match level {
209            Elevation::Flat => &self.flat,
210            Elevation::Raised => &self.raised,
211            Elevation::Overlay => &self.overlay,
212            Elevation::Modal => &self.modal,
213        }
214    }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct ZIndices {
219    pub content: i32,
220    pub sticky: i32,
221    pub dock: i32,
222    pub popover: i32,
223    pub tooltip: i32,
224    pub modal: i32,
225    pub toast: i32,
226}
227
228impl ZIndices {
229    pub fn get(&self, layer: Layer) -> i32 {
230        match layer {
231            Layer::Content => self.content,
232            Layer::Sticky => self.sticky,
233            Layer::Dock => self.dock,
234            Layer::Popover => self.popover,
235            Layer::Tooltip => self.tooltip,
236            Layer::Modal => self.modal,
237            Layer::Toast => self.toast,
238        }
239    }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq)]
243pub struct Effects {
244    pub edge_fade_band: f32,
245    pub selected_ring_alpha: f32,
246    pub focus_ring_width: f32,
247    pub focus_ring_alpha: f32,
248    pub glow_alpha: f32,
249    pub glow_blur: f32,
250    pub glass_alpha: f32,
251    pub glass_blur: f32,
252}
253
254impl Theme {
255    pub fn studio_dark() -> Self {
256        Self::from_tokens(gpui_kit_tokens::studio_dark(), Density::default())
257    }
258
259    pub fn studio_light() -> Self {
260        Self::from_tokens(gpui_kit_tokens::studio_light(), Density::default())
261    }
262
263    /// Builds a theme from any validated token document at one density.
264    ///
265    /// Density scales spacing, control geometry and type independently, and
266    /// rounds to whole pixels so compact layouts stay on the pixel grid.
267    pub fn from_tokens(tokens: &TokenDocument, density: Density) -> Self {
268        let scale = tokens.density(density);
269        let style = |step| {
270            let step = tokens.type_step(step);
271            TypeStyle {
272                size: scale_font(step.size, scale),
273                line_height: scale_font(step.line_height, scale),
274                weight: step.weight,
275            }
276        };
277        Self {
278            id: tokens.meta.id.clone().into(),
279            name: tokens.meta.name.clone().into(),
280            appearance: tokens.meta.appearance,
281            density,
282            colors: Colors {
283                canvas: color(tokens.surface(Surface::Canvas)),
284                sunken: color(tokens.surface(Surface::Sunken)),
285                panel: color(tokens.surface(Surface::Panel)),
286                raised: color(tokens.surface(Surface::Raised)),
287                overlay: color(tokens.surface(Surface::Overlay)),
288                text: color(tokens.text(TextTone::Primary)),
289                text_muted: color(tokens.text(TextTone::Muted)),
290                text_faint: color(tokens.text(TextTone::Faint)),
291                text_on_accent: color(tokens.text(TextTone::OnAccent)),
292                hover: color(tokens.interactive(InteractiveColor::Hover)),
293                active: color(tokens.interactive(InteractiveColor::Active)),
294                selected: color(tokens.interactive(InteractiveColor::Selected)),
295                hairline: color(tokens.interactive(InteractiveColor::Hairline)),
296                hairline_strong: color(tokens.interactive(InteractiveColor::HairlineStrong)),
297                focus: color(tokens.interactive(InteractiveColor::Focus)),
298                accent: color(tokens.semantic(SemanticColor::Accent)),
299                accent_strong: color(tokens.semantic(SemanticColor::AccentStrong)),
300                danger: color(tokens.semantic(SemanticColor::Danger)),
301                warning: color(tokens.semantic(SemanticColor::Warning)),
302                success: color(tokens.semantic(SemanticColor::Success)),
303                info: color(tokens.semantic(SemanticColor::Info)),
304                loader_gradient: tokens.loader_gradient().map(color),
305            },
306            typography: Typography {
307                sans: tokens.typography.sans.family.clone().into(),
308                sans_fallback: tokens
309                    .typography
310                    .sans
311                    .platform_fallback()
312                    .to_string()
313                    .into(),
314                mono: tokens.typography.mono.family.clone().into(),
315                mono_fallback: tokens
316                    .typography
317                    .mono
318                    .platform_fallback()
319                    .to_string()
320                    .into(),
321                caption: style(TypeScale::Caption),
322                label: style(TypeScale::Label),
323                body: style(TypeScale::Body),
324                strong: style(TypeScale::Strong),
325                subtitle: style(TypeScale::Subtitle),
326                title: style(TypeScale::Title),
327                code: style(TypeScale::Code),
328            },
329            spacing: Spacing {
330                xs: scale_space(tokens.spacing(Space::Xs), scale),
331                sm: scale_space(tokens.spacing(Space::Sm), scale),
332                md: scale_space(tokens.spacing(Space::Md), scale),
333                lg: scale_space(tokens.spacing(Space::Lg), scale),
334                xl: scale_space(tokens.spacing(Space::Xl), scale),
335                xxl: scale_space(tokens.spacing(Space::Xxl), scale),
336            },
337            radii: Radii {
338                small: tokens.radius(Radius::Small),
339                control: tokens.radius(Radius::Control),
340                card: tokens.radius(Radius::Card),
341                dialog: tokens.radius(Radius::Dialog),
342                bubble: tokens.radius(Radius::Bubble),
343                pill: tokens.radius(Radius::Pill),
344            },
345            control: {
346                let metrics = |size| {
347                    let step = tokens.control(size);
348                    ControlMetrics {
349                        height: scale_control(step.height, scale),
350                        padding_x: scale_control(step.padding_x, scale),
351                        gap: scale_control(step.gap, scale),
352                        font_size: scale_font(step.font_size, scale),
353                        icon_size: scale_control(step.icon_size, scale),
354                    }
355                };
356                Control {
357                    xs: metrics(ControlSize::Xs),
358                    sm: metrics(ControlSize::Sm),
359                    md: metrics(ControlSize::Md),
360                    lg: metrics(ControlSize::Lg),
361                }
362            },
363            borders: Borders {
364                hairline: tokens.border_width(BorderWeight::Hairline),
365                thick: tokens.border_width(BorderWeight::Thick),
366            },
367            opacity: Opacity {
368                disabled: tokens.opacity(OpacityRole::Disabled),
369                muted: tokens.opacity(OpacityRole::Muted),
370                scrim: tokens.opacity(OpacityRole::Scrim),
371            },
372            motion: Motion {
373                instant_ms: millis(tokens, MotionDuration::Instant),
374                quick_ms: millis(tokens, MotionDuration::Quick),
375                menu_ms: millis(tokens, MotionDuration::Menu),
376                dialog_ms: millis(tokens, MotionDuration::Dialog),
377                resize_ms: millis(tokens, MotionDuration::Resize),
378                entrance_ms: millis(tokens, MotionDuration::Entrance),
379                spin_ms: millis(tokens, MotionDuration::Spin),
380                slow_ms: millis(tokens, MotionDuration::Slow),
381                stagger_step_ms: millis(tokens, MotionDuration::StaggerStep),
382                pulse_ms: millis(tokens, MotionDuration::Pulse),
383                shimmer_ms: millis(tokens, MotionDuration::Shimmer),
384                toast_ms: millis(tokens, MotionDuration::Toast),
385                linear: tokens.easing(MotionEasing::Linear),
386                standard: tokens.easing(MotionEasing::Standard),
387                ease_in: tokens.easing(MotionEasing::EaseIn),
388                ease_out: tokens.easing(MotionEasing::EaseOut),
389                ease_in_out: tokens.easing(MotionEasing::EaseInOut),
390                emphasized: tokens.easing(MotionEasing::Emphasized),
391                overshoot: tokens.easing(MotionEasing::Overshoot),
392                exit: tokens.easing(MotionEasing::Exit),
393                settle: tokens.easing(MotionEasing::Settle),
394                snappy: tokens.spring(SpringPreset::Snappy),
395                smooth: tokens.spring(SpringPreset::Smooth),
396                bouncy: tokens.spring(SpringPreset::Bouncy),
397                grab: tokens.spring(SpringPreset::Grab),
398                press_offset: tokens.press_offset(),
399                hover_lift: tokens.hover_lift(),
400                flick_velocity: tokens.flick_velocity(),
401                rubber_band_tension: tokens.rubber_band_tension(),
402            },
403            elevation: Elevations {
404                flat: shadow(tokens, Elevation::Flat),
405                raised: shadow(tokens, Elevation::Raised),
406                overlay: shadow(tokens, Elevation::Overlay),
407                modal: shadow(tokens, Elevation::Modal),
408            },
409            z_index: ZIndices {
410                content: tokens.z_index(Layer::Content),
411                sticky: tokens.z_index(Layer::Sticky),
412                dock: tokens.z_index(Layer::Dock),
413                popover: tokens.z_index(Layer::Popover),
414                tooltip: tokens.z_index(Layer::Tooltip),
415                modal: tokens.z_index(Layer::Modal),
416                toast: tokens.z_index(Layer::Toast),
417            },
418            effects: Effects {
419                edge_fade_band: tokens.effect.edge_fade_band,
420                selected_ring_alpha: tokens.effect.selected_ring_alpha,
421                focus_ring_width: tokens.effect.focus_ring_width,
422                focus_ring_alpha: tokens.effect.focus_ring_alpha,
423                glow_alpha: tokens.effect.glow_alpha,
424                glow_blur: tokens.effect.glow_blur,
425                glass_alpha: tokens.effect.glass_alpha,
426                glass_blur: tokens.effect.glass_blur,
427            },
428        }
429    }
430
431    pub fn surface(&self, surface: Surface) -> Hsla {
432        match surface {
433            Surface::Canvas => self.colors.canvas,
434            Surface::Sunken => self.colors.sunken,
435            Surface::Panel => self.colors.panel,
436            Surface::Raised => self.colors.raised,
437            Surface::Overlay => self.colors.overlay,
438        }
439    }
440
441    pub fn text_color(&self, tone: TextTone) -> Hsla {
442        match tone {
443            TextTone::Primary => self.colors.text,
444            TextTone::Muted => self.colors.text_muted,
445            TextTone::Faint => self.colors.text_faint,
446            TextTone::OnAccent => self.colors.text_on_accent,
447        }
448    }
449
450    pub fn semantic_color(&self, color: SemanticColor) -> Hsla {
451        match color {
452            SemanticColor::Accent => self.colors.accent,
453            SemanticColor::AccentStrong => self.colors.accent_strong,
454            SemanticColor::Danger => self.colors.danger,
455            SemanticColor::Warning => self.colors.warning,
456            SemanticColor::Success => self.colors.success,
457            SemanticColor::Info => self.colors.info,
458        }
459    }
460
461    pub fn space(&self, step: Space) -> f32 {
462        match step {
463            Space::Xs => self.spacing.xs,
464            Space::Sm => self.spacing.sm,
465            Space::Md => self.spacing.md,
466            Space::Lg => self.spacing.lg,
467            Space::Xl => self.spacing.xl,
468            Space::Xxl => self.spacing.xxl,
469        }
470    }
471
472    pub fn radius(&self, step: Radius) -> f32 {
473        match step {
474            Radius::Small => self.radii.small,
475            Radius::Control => self.radii.control,
476            Radius::Card => self.radii.card,
477            Radius::Dialog => self.radii.dialog,
478            Radius::Bubble => self.radii.bubble,
479            Radius::Pill => self.radii.pill,
480        }
481    }
482
483    pub fn type_style(&self, scale: TypeScale) -> TypeStyle {
484        match scale {
485            TypeScale::Caption => self.typography.caption,
486            TypeScale::Label => self.typography.label,
487            TypeScale::Body => self.typography.body,
488            TypeScale::Strong => self.typography.strong,
489            TypeScale::Subtitle => self.typography.subtitle,
490            TypeScale::Title => self.typography.title,
491            TypeScale::Code => self.typography.code,
492        }
493    }
494
495    pub fn easing(&self, easing: MotionEasing) -> [f32; 4] {
496        match easing {
497            MotionEasing::Linear => self.motion.linear,
498            MotionEasing::Standard => self.motion.standard,
499            MotionEasing::EaseIn => self.motion.ease_in,
500            MotionEasing::EaseOut => self.motion.ease_out,
501            MotionEasing::EaseInOut => self.motion.ease_in_out,
502            MotionEasing::Emphasized => self.motion.emphasized,
503            MotionEasing::Overshoot => self.motion.overshoot,
504            MotionEasing::Exit => self.motion.exit,
505            MotionEasing::Settle => self.motion.settle,
506        }
507    }
508
509    pub fn spring(&self, preset: SpringPreset) -> SpringTokens {
510        match preset {
511            SpringPreset::Snappy => self.motion.snappy,
512            SpringPreset::Smooth => self.motion.smooth,
513            SpringPreset::Bouncy => self.motion.bouncy,
514            SpringPreset::Grab => self.motion.grab,
515        }
516    }
517
518    pub fn shadow(&self, level: Elevation) -> &[BoxShadow] {
519        self.elevation.get(level)
520    }
521
522    pub fn layer(&self, layer: Layer) -> i32 {
523        self.z_index.get(layer)
524    }
525
526    /// Installs the bundled theme registry. Idempotent.
527    pub fn install(cx: &mut App) {
528        if !cx.has_global::<ThemeRegistry>() {
529            cx.set_global(ThemeRegistry::new());
530        }
531    }
532
533    pub fn get(cx: &App) -> &Self {
534        cx.global::<ThemeRegistry>().active()
535    }
536
537    /// The ring drawn around whichever control currently has the keyboard.
538    ///
539    /// It spreads outward in the focus colour, so it reads differently from
540    /// [`Self::selected_ring`]: focus says where the next keystroke goes,
541    /// selection says which answer is current, and a reader that cannot tell
542    /// the two apart cannot tell what pressing a key would do.
543    pub fn focus_ring(&self) -> Vec<BoxShadow> {
544        vec![BoxShadow {
545            color: self.colors.focus.opacity(self.effects.focus_ring_alpha),
546            offset: point(px(0.0), px(0.0)),
547            blur_radius: px(0.0),
548            spread_radius: px(self.effects.focus_ring_width),
549            inset: false,
550        }]
551    }
552
553    pub fn selected_ring(&self) -> Vec<BoxShadow> {
554        vec![BoxShadow {
555            color: self.colors.text.opacity(self.effects.selected_ring_alpha),
556            offset: point(px(0.0), px(0.0)),
557            blur_radius: px(0.0),
558            spread_radius: px(1.0),
559            inset: true,
560        }]
561    }
562
563    /// The colour a surface in a named state bleeds into the pixels around it.
564    ///
565    /// It is the state itself made visible at the edge, which is what lets a
566    /// surface report "running" or "failed" without a border drawn round it.
567    /// Blurred and unoffset, so nothing about it reads as a line.
568    pub fn glow(&self, color: Hsla) -> Vec<BoxShadow> {
569        vec![BoxShadow {
570            color: color.opacity(self.effects.glow_alpha),
571            offset: point(px(0.0), px(0.0)),
572            blur_radius: px(self.effects.glow_blur),
573            spread_radius: px(0.0),
574            inset: false,
575        }]
576    }
577}
578
579impl Default for Theme {
580    fn default() -> Self {
581        Self::studio_dark()
582    }
583}
584
585/// The set of themes an application can switch between at runtime.
586#[derive(Debug)]
587pub struct ThemeRegistry {
588    tokens: Vec<Arc<TokenDocument>>,
589    active: usize,
590    density: Density,
591    theme: Theme,
592}
593
594impl Global for ThemeRegistry {}
595
596impl ThemeRegistry {
597    pub fn global(cx: &App) -> &Self {
598        cx.global::<Self>()
599    }
600
601    pub fn new() -> Self {
602        let tokens: Vec<Arc<TokenDocument>> = bundled()
603            .into_iter()
604            .map(|document| Arc::new(document.clone()))
605            .collect();
606        let theme = Theme::from_tokens(&tokens[0], Density::default());
607        Self {
608            tokens,
609            active: 0,
610            density: Density::default(),
611            theme,
612        }
613    }
614
615    /// Adds or replaces a theme. A registered id replaces the earlier document
616    /// so an application can override a bundled theme without shadowing it.
617    pub fn register(&mut self, tokens: TokenDocument) {
618        let id = tokens.meta.id.clone();
619        match self.tokens.iter().position(|other| other.meta.id == id) {
620            Some(index) => self.tokens[index] = Arc::new(tokens),
621            None => self.tokens.push(Arc::new(tokens)),
622        }
623        self.rebuild();
624    }
625
626    pub fn register_json(&mut self, json: &str) -> Result<(), TokenError> {
627        self.register(TokenDocument::parse(json)?);
628        Ok(())
629    }
630
631    pub fn ids(&self) -> Vec<SharedString> {
632        self.tokens
633            .iter()
634            .map(|tokens| SharedString::from(tokens.meta.id.clone()))
635            .collect()
636    }
637
638    pub fn active(&self) -> &Theme {
639        &self.theme
640    }
641
642    pub fn density(&self) -> Density {
643        self.density
644    }
645
646    /// Returns false when the id is not registered, leaving the active theme
647    /// untouched rather than falling back to a default the caller did not ask
648    /// for.
649    pub fn activate(&mut self, id: &str) -> bool {
650        let Some(index) = self.tokens.iter().position(|tokens| tokens.meta.id == id) else {
651            return false;
652        };
653        self.active = index;
654        self.rebuild();
655        true
656    }
657
658    pub fn set_density(&mut self, density: Density) {
659        self.density = density;
660        self.rebuild();
661    }
662
663    fn rebuild(&mut self) {
664        self.theme = Theme::from_tokens(&self.tokens[self.active], self.density);
665    }
666}
667
668impl Default for ThemeRegistry {
669    fn default() -> Self {
670        Self::new()
671    }
672}
673
674/// Switches the active theme and repaints every window.
675pub fn activate_theme(id: &str, cx: &mut App) -> bool {
676    let switched = cx.update_global::<ThemeRegistry, bool>(|registry, _| registry.activate(id));
677    if switched {
678        cx.refresh_windows();
679    }
680    switched
681}
682
683/// Changes the density axis and repaints every window.
684pub fn set_density(density: Density, cx: &mut App) {
685    cx.update_global::<ThemeRegistry, ()>(|registry, _| registry.set_density(density));
686    cx.refresh_windows();
687}
688
689fn scale_space(value: f32, scale: DensityScale) -> f32 {
690    (value * scale.space).round().max(1.0)
691}
692
693fn scale_control(value: f32, scale: DensityScale) -> f32 {
694    (value * scale.control).round().max(1.0)
695}
696
697fn scale_font(value: f32, scale: DensityScale) -> f32 {
698    ((value * scale.font) * 2.0).round() / 2.0
699}
700
701fn shadow(tokens: &TokenDocument, level: Elevation) -> Vec<BoxShadow> {
702    let step = tokens.elevation(level);
703    if step.color.alpha == 0.0 {
704        return Vec::new();
705    }
706    vec![BoxShadow {
707        color: color(step.color),
708        offset: point(px(0.0), px(step.y)),
709        blur_radius: px(step.blur),
710        spread_radius: px(step.spread),
711        inset: false,
712    }]
713}
714
715fn millis(tokens: &gpui_kit_tokens::TokenDocument, step: MotionDuration) -> u64 {
716    tokens.motion_duration(step).as_millis() as u64
717}
718
719fn color(value: Color) -> Hsla {
720    Hsla::from(Rgba {
721        r: value.red,
722        g: value.green,
723        b: value.blue,
724        a: value.alpha,
725    })
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    /// The library groups content with colour rather than with a line drawn
733    /// round it, so a step between two surfaces has to be visible on its own.
734    /// Below this the step stops reading and a border is doing the work
735    /// instead, which is the arrangement this threshold exists to prevent
736    /// anyone drifting back into.
737    const MIN_SURFACE_STEP: f32 = 0.02;
738
739    #[test]
740    fn a_surface_step_is_visible_without_a_border_to_help_it() {
741        for theme in [Theme::studio_dark(), Theme::studio_light()] {
742            let steps = [
743                ("sunken to panel", theme.colors.sunken, theme.colors.panel),
744                ("canvas to panel", theme.colors.canvas, theme.colors.panel),
745            ];
746            for (name, lower, upper) in steps {
747                assert!(
748                    (upper.l - lower.l).abs() >= MIN_SURFACE_STEP,
749                    "{} in {}: {} is too close to {} to group anything",
750                    name,
751                    theme.id,
752                    lower.l,
753                    upper.l
754                );
755            }
756        }
757    }
758
759    #[test]
760    fn every_theme_separates_surfaces_and_text_emphasis() {
761        for theme in [Theme::studio_dark(), Theme::studio_light()] {
762            assert!(theme.colors.sunken.l < theme.colors.panel.l);
763            assert!(theme.colors.canvas.l < theme.colors.panel.l);
764            assert!(theme.colors.panel.l <= theme.colors.raised.l);
765
766            // Emphasis is distance from the canvas, which inverts with
767            // appearance, so compare magnitudes rather than raw lightness.
768            let emphasis = |tone: Hsla| (tone.l - theme.colors.canvas.l).abs();
769            assert!(emphasis(theme.colors.text) > emphasis(theme.colors.text_muted));
770            assert!(emphasis(theme.colors.text_muted) > emphasis(theme.colors.text_faint));
771        }
772    }
773
774    #[test]
775    fn compact_density_shrinks_geometry_without_touching_color() {
776        let comfortable = Theme::from_tokens(gpui_kit_tokens::studio_dark(), Density::Comfortable);
777        let compact = Theme::from_tokens(gpui_kit_tokens::studio_dark(), Density::Compact);
778        assert!(compact.spacing.lg < comfortable.spacing.lg);
779        assert!(
780            compact.control.get(ControlSize::Md).height
781                < comfortable.control.get(ControlSize::Md).height
782        );
783        assert!(compact.typography.body.size < comfortable.typography.body.size);
784        assert_eq!(compact.colors.accent, comfortable.colors.accent);
785        assert_eq!(compact.radii.card, comfortable.radii.card);
786    }
787
788    #[test]
789    fn density_keeps_geometry_on_the_pixel_grid() {
790        let compact = Theme::from_tokens(gpui_kit_tokens::studio_dark(), Density::Compact);
791        for size in ControlSize::ALL {
792            let metrics = compact.control.get(size);
793            assert_eq!(metrics.height.fract(), 0.0);
794            assert_eq!(metrics.padding_x.fract(), 0.0);
795        }
796        assert_eq!(compact.spacing.md.fract(), 0.0);
797    }
798
799    #[test]
800    fn the_registry_switches_themes_and_refuses_unknown_ids() {
801        let mut registry = ThemeRegistry::new();
802        assert_eq!(registry.active().id, "studio-dark");
803        assert!(registry.activate("studio-light"));
804        assert_eq!(registry.active().appearance, Appearance::Light);
805        assert!(!registry.activate("studio-solarized"));
806        assert_eq!(registry.active().id, "studio-light");
807    }
808
809    #[test]
810    fn a_registered_theme_replaces_the_bundled_one_with_the_same_id() {
811        let mut registry = ThemeRegistry::new();
812        let before = registry.ids().len();
813        registry
814            .register_json(gpui_kit_tokens::studio_dark_json())
815            .expect("bundled json is valid");
816        assert_eq!(registry.ids().len(), before);
817    }
818
819    #[test]
820    fn invalid_contrast_is_reported_before_the_registry_changes() {
821        let mut registry = ThemeRegistry::new();
822        let before = registry.ids();
823        let mut value: serde_json::Value =
824            serde_json::from_str(gpui_kit_tokens::studio_dark_json()).expect("bundled JSON");
825        value["meta"]["id"] = serde_json::json!("low-contrast");
826        value["color"]["text"]["primary"] = value["color"]["surface"]["canvas"].clone();
827
828        let error = registry
829            .register_json(&value.to_string())
830            .expect_err("invisible text must not register");
831        assert!(error.to_string().contains("text.primary on surface.canvas"));
832        assert_eq!(registry.ids(), before);
833    }
834
835    #[test]
836    fn density_survives_a_theme_switch() {
837        let mut registry = ThemeRegistry::new();
838        registry.set_density(Density::Compact);
839        registry.activate("studio-light");
840        assert_eq!(registry.active().density, Density::Compact);
841    }
842
843    #[test]
844    fn flat_elevation_costs_nothing_and_deeper_layers_cast_more() {
845        let theme = Theme::studio_dark();
846        assert!(theme.shadow(Elevation::Flat).is_empty());
847        assert!(
848            theme.shadow(Elevation::Modal)[0].blur_radius
849                > theme.shadow(Elevation::Raised)[0].blur_radius
850        );
851        assert!(theme.layer(Layer::Toast) > theme.layer(Layer::Popover));
852    }
853
854    #[test]
855    fn repeated_semantic_metrics_are_token_backed() {
856        let theme = Theme::studio_dark();
857        assert_eq!(theme.spacing.lg, 16.0);
858        assert_eq!(theme.radii.card, 12.0);
859        assert_eq!(theme.radii.dialog, 16.0);
860        assert_eq!(theme.motion.menu_ms, 140);
861    }
862
863    #[test]
864    fn control_metrics_grow_with_size() {
865        let theme = Theme::studio_dark();
866        let heights: Vec<f32> = ControlSize::ALL
867            .iter()
868            .map(|size| theme.control.get(*size).height)
869            .collect();
870        assert!(heights.windows(2).all(|window| window[0] < window[1]));
871        assert_eq!(theme.borders.hairline, 1.0);
872        assert!(theme.opacity.disabled < 1.0);
873    }
874
875    #[test]
876    fn focus_and_selection_do_not_look_alike() {
877        for theme in [Theme::studio_dark(), Theme::studio_light()] {
878            let focus = theme.focus_ring();
879            let selected = theme.selected_ring();
880            assert_eq!(focus.len(), 1);
881            assert_ne!(focus[0].color, selected[0].color);
882            assert!(!focus[0].inset && selected[0].inset);
883            // A ring that reserved space would move the layout the moment the
884            // keyboard arrived on a control.
885            assert_eq!(focus[0].offset, point(px(0.0), px(0.0)));
886            assert!(focus[0].spread_radius > px(0.0));
887        }
888    }
889
890    #[test]
891    fn selected_ring_does_not_change_layout() {
892        let theme = Theme::studio_dark();
893        let ring = theme.selected_ring();
894        assert_eq!(ring.len(), 1);
895        assert!(ring[0].inset);
896        assert_eq!(ring[0].spread_radius, px(1.0));
897    }
898}