1use 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
16pub 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 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 pub press_offset: f32,
188 pub hover_lift: f32,
190 pub flick_velocity: f32,
192 pub rubber_band_tension: f32,
194}
195
196#[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 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 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 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 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#[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 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 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
674pub 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
683pub 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 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 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 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}