1use gpui::{Hsla, Pixels, SharedString, WindowAppearance};
7use herogpui_core::Color;
8
9use crate::layout::LayoutTheme;
10use crate::semantic::{SurfaceColor, ThemeColors};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
16pub enum Appearance {
17 Light,
19 Dark,
21}
22
23impl From<WindowAppearance> for Appearance {
32 fn from(appearance: WindowAppearance) -> Self {
33 match appearance {
34 WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
35 WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
36 }
37 }
38}
39
40#[derive(Clone, Debug)]
42#[non_exhaustive]
43pub struct Theme {
44 pub id: SharedString,
46 pub appearance: Appearance,
48 pub colors: ThemeColors,
50 pub layout: LayoutTheme,
52 pub components: crate::ComponentThemes,
54}
55
56impl Theme {
57 pub fn light() -> Self {
59 Self {
60 id: "light".into(),
61 appearance: Appearance::Light,
62 colors: ThemeColors::light(),
63 layout: LayoutTheme::light(),
64 components: crate::ComponentThemes::default(),
65 }
66 }
67
68 pub fn dark() -> Self {
70 Self {
71 id: "dark".into(),
72 appearance: Appearance::Dark,
73 colors: ThemeColors::dark(),
74 layout: LayoutTheme::dark(),
75 components: crate::ComponentThemes::default(),
76 }
77 }
78
79 pub fn builder(id: impl Into<SharedString>, base: Theme) -> ThemeBuilder {
86 ThemeBuilder { theme: base }.id(id)
87 }
88
89 pub fn is_dark(&self) -> bool {
91 self.appearance == Appearance::Dark
92 }
93}
94
95#[must_use = "a ThemeBuilder does nothing until `build` is called"]
98pub struct ThemeBuilder {
99 theme: Theme,
100}
101
102impl ThemeBuilder {
103 pub fn components(mut self, components: crate::ComponentThemes) -> Self {
105 self.theme.components = components;
106 self
107 }
108
109 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
111 self.theme.id = id.into();
112 self
113 }
114
115 pub fn appearance(mut self, appearance: Appearance) -> Self {
117 self.theme.appearance = appearance;
118 self
119 }
120
121 pub fn radius(mut self, radius: Pixels) -> Self {
126 self.theme.layout.radius = radius;
127 self.theme.layout.field_radius = radius * 1.5;
128 self
129 }
130
131 pub fn field_radius(mut self, radius: Pixels) -> Self {
133 self.theme.layout.field_radius = radius;
134 self
135 }
136
137 pub fn border_width(mut self, width: Pixels) -> Self {
139 self.theme.layout.border_width = width;
140 self
141 }
142
143 pub fn disabled_opacity(mut self, v: f32) -> Self {
145 self.theme.layout.disabled_opacity = v;
146 self
147 }
148
149 pub fn cursor_interactive(mut self, cursor: gpui::CursorStyle) -> Self {
152 self.theme.layout.cursor_interactive = cursor;
153 self
154 }
155
156 pub fn tabs_hover_opacity(mut self, v: f32) -> Self {
159 self.theme.layout.tabs_hover_opacity = if v.is_finite() {
160 v.clamp(0.0, 1.0)
161 } else {
162 0.7
163 };
164 self
165 }
166
167 pub fn tooltip_cooldown_ms(mut self, ms: u64) -> Self {
171 self.theme.layout.tooltip_cooldown_ms = ms;
172 self
173 }
174
175 pub fn long_press_ms(mut self, ms: u64) -> Self {
177 self.theme.layout.long_press_ms = ms;
178 self
179 }
180
181 pub fn hover_fade_ms(mut self, ms: u64) -> Self {
184 self.theme.layout.hover_fade_ms = ms;
185 self
186 }
187
188 pub fn tooltip_delay_ms(mut self, ms: u64) -> Self {
190 self.theme.layout.tooltip_delay_ms = ms;
191 self
192 }
193
194 pub fn tooltip_close_delay_ms(mut self, ms: u64) -> Self {
196 self.theme.layout.tooltip_close_delay_ms = ms;
197 self
198 }
199
200 pub fn background(mut self, c: Hsla) -> Self {
204 self.theme.colors.background = c;
205 self
206 }
207
208 pub fn foreground(mut self, c: Hsla) -> Self {
210 self.theme.colors.foreground = c;
211 self.theme.colors.scrollbar = herogpui_core::with_alpha(c, 0.15);
212 self
213 }
214
215 pub fn muted(mut self, c: Hsla) -> Self {
217 self.theme.colors.muted = c;
218 self
219 }
220
221 pub fn border(mut self, c: Hsla) -> Self {
223 self.theme.colors.border = c;
224 self
225 }
226
227 pub fn separator(mut self, c: Hsla) -> Self {
229 self.theme.colors.separator = c;
230 self
231 }
232
233 pub fn focus(mut self, c: Hsla) -> Self {
235 self.theme.colors.focus = c;
236 self
237 }
238
239 pub fn link(mut self, c: Hsla) -> Self {
241 self.theme.colors.link = c;
242 self
243 }
244
245 pub fn backdrop(mut self, c: Hsla) -> Self {
247 self.theme.colors.backdrop = c;
248 self
249 }
250
251 pub fn surface(mut self, background: Hsla, foreground: Hsla) -> Self {
255 self.theme.colors.surface = SurfaceColor {
256 background,
257 foreground,
258 };
259 self
260 }
261
262 pub fn surface_levels(mut self, secondary: Hsla, tertiary: Hsla) -> Self {
264 self.theme.colors.surface_secondary = secondary;
265 self.theme.colors.surface_tertiary = tertiary;
266 self
267 }
268
269 pub fn overlay(mut self, background: Hsla, foreground: Hsla) -> Self {
271 self.theme.colors.overlay = SurfaceColor {
272 background,
273 foreground,
274 };
275 self
276 }
277
278 pub fn segment(mut self, background: Hsla, foreground: Hsla) -> Self {
280 self.theme.colors.segment = SurfaceColor {
281 background,
282 foreground,
283 };
284 self
285 }
286
287 pub fn role(mut self, role: Color, color: Hsla, foreground: Hsla) -> Self {
298 let slot = self.theme.colors.role_mut(role);
299 slot.color = color;
300 slot.foreground = foreground;
301 match role {
302 Color::Default => self.theme.colors.field.background = color,
303 Color::Accent => self.theme.colors.focus = color,
304 Color::Success | Color::Warning | Color::Danger => {}
305 }
306 self
307 }
308
309 pub fn vibrant_palette(mut self, vibrant: bool) -> Self {
316 self.theme.colors.set_vibrant_palette(vibrant);
317 self
318 }
319
320 pub fn accent(self, color: Hsla) -> Self {
323 let fg = herogpui_core::readable_color(color);
324 self.role(Color::Accent, color, fg)
325 }
326
327 pub fn role_hover(mut self, role: Color, hover: Hsla) -> Self {
338 let slot = self.theme.colors.role_mut(role);
339 *slot = slot.with_hover(hover);
340 self
341 }
342
343 pub fn accent_hover(self, hover: Hsla) -> Self {
346 self.role_hover(Color::Accent, hover)
347 }
348
349 pub fn field(mut self, background: Hsla, foreground: Hsla) -> Self {
353 self.theme.colors.field.background = background;
354 self.theme.colors.field.foreground = foreground;
355 self
356 }
357
358 pub fn field_placeholder(mut self, c: Hsla) -> Self {
360 self.theme.colors.field.placeholder = c;
361 self
362 }
363
364 pub fn field_border(mut self, c: Hsla) -> Self {
366 self.theme.colors.field.border = c;
367 self
368 }
369
370 pub fn build(self) -> Theme {
372 self.theme
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use herogpui_core::{mix_oklab, oklch, with_alpha};
380
381 #[test]
382 fn the_builder_overrides_the_interactive_cursor_and_nothing_else() {
383 let base = Theme::light();
384 assert_eq!(
385 base.layout.cursor_interactive,
386 gpui::CursorStyle::PointingHand
387 );
388
389 let theme = Theme::builder("arrow", base.clone())
390 .cursor_interactive(gpui::CursorStyle::Arrow)
391 .build();
392
393 assert_eq!(theme.layout.cursor_interactive, gpui::CursorStyle::Arrow);
394 assert_eq!(theme.layout.radius, base.layout.radius);
395 assert!(
396 (theme.layout.disabled_opacity - base.layout.disabled_opacity).abs() < f32::EPSILON
397 );
398 assert_eq!(theme.colors.background, base.colors.background);
399 }
400
401 #[test]
402 fn customisation_tokens_flow_through_the_builder_and_clamp() {
403 let theme = Theme::builder("custom", Theme::light())
404 .tabs_hover_opacity(2.0)
405 .tooltip_cooldown_ms(250)
406 .long_press_ms(350)
407 .hover_fade_ms(0)
408 .tooltip_delay_ms(50)
409 .tooltip_close_delay_ms(75)
410 .build();
411 assert!((theme.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
412 assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
413 assert_eq!(theme.layout.long_press_ms, 350);
414 assert_eq!(theme.layout.hover_fade_ms, 0);
415 assert_eq!(theme.layout.tooltip_delay_ms, 50);
416 assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
417
418 let nan = Theme::builder("nan", Theme::light())
419 .tabs_hover_opacity(f32::NAN)
420 .build();
421 assert!((nan.layout.tabs_hover_opacity - 0.7).abs() < f32::EPSILON);
422 }
423
424 #[test]
425 fn overriding_foreground_recomputes_scrollbar_without_changing_other_tokens() {
426 let base = Theme::light();
427 let foreground = oklch(0.30, 0.05, 120.0);
428 let background = oklch(0.90, 0.01, 286.0);
429 let unchanged = (
430 base.colors.muted,
431 base.colors.border,
432 base.colors.separator,
433 base.colors.focus,
434 base.layout.radius,
435 base.layout.disabled_opacity,
436 );
437 let theme = Theme::builder("brand", base)
438 .background(background)
439 .foreground(foreground)
440 .build();
441
442 assert_eq!(
443 (
444 theme.colors.scrollbar,
445 theme.colors.background,
446 theme.colors.muted,
447 theme.colors.border,
448 theme.colors.separator,
449 theme.colors.focus,
450 theme.layout.radius,
451 theme.layout.disabled_opacity,
452 ),
453 (
454 with_alpha(foreground, 0.15),
455 background,
456 unchanged.0,
457 unchanged.1,
458 unchanged.2,
459 unchanged.3,
460 unchanged.4,
461 unchanged.5,
462 )
463 );
464 }
465
466 #[test]
467 fn overriding_a_role_keeps_its_soft_semantics() {
468 let theme = Theme::builder("brand", Theme::light())
471 .role(
472 Color::Success,
473 oklch(0.55, 0.18, 145.0),
474 oklch(1.0, 0.0, 0.0),
475 )
476 .build();
477 assert!((theme.colors.success.soft().a - 0.15).abs() < 1e-4);
478 assert!((theme.colors.success.soft_hover().a - 0.20).abs() < 1e-4);
479 assert_eq!(
480 theme
481 .colors
482 .success
483 .soft_foreground(theme.colors.foreground),
484 mix_oklab(
485 theme.colors.success.color,
486 theme.colors.foreground,
487 60.0 / 140.0
488 )
489 );
490 }
491
492 #[test]
493 fn a_default_role_override_keeps_the_half_strength_soft() {
494 let theme = Theme::builder("brand", Theme::light())
495 .role(
496 Color::Default,
497 oklch(0.90, 0.01, 286.0),
498 oklch(0.20, 0.01, 286.0),
499 )
500 .build();
501 assert!((theme.colors.default.soft().a - 0.50).abs() < 1e-4);
502 assert!((theme.colors.default.soft_hover().a - 0.60).abs() < 1e-4);
503 assert_eq!(
504 theme
505 .colors
506 .default
507 .soft_foreground(theme.colors.foreground),
508 theme.colors.default.foreground
509 );
510 }
511
512 #[test]
513 fn the_vibrant_palette_reweights_only_the_soft_foregrounds() {
514 for base in [Theme::light(), Theme::dark()] {
517 let plain = Theme::builder("plain", base.clone()).build();
518 let vibrant = Theme::builder("vibrant", base.clone())
519 .vibrant_palette(true)
520 .build();
521 assert!(!plain.colors.vibrant_palette());
522 assert!(vibrant.colors.vibrant_palette());
523
524 for role in [Color::Accent, Color::Success, Color::Warning, Color::Danger] {
525 let r = vibrant.colors.role(role);
526 assert_eq!(
527 r.soft_foreground(vibrant.colors.foreground),
528 mix_oklab(r.color, vibrant.colors.foreground, 0.08),
529 "{role:?} soft-foreground is not the 92/8 vibrant mix"
530 );
531 assert_ne!(
532 r.soft_foreground(vibrant.colors.foreground),
533 plain
534 .colors
535 .role(role)
536 .soft_foreground(plain.colors.foreground),
537 "{role:?} soft-foreground did not move"
538 );
539 }
540
541 assert_eq!(
544 vibrant
545 .colors
546 .default
547 .soft_foreground(vibrant.colors.foreground),
548 plain.colors.default.foreground
549 );
550
551 for role in Color::ALL {
553 let (a, b) = (plain.colors.role(role), vibrant.colors.role(role));
554 assert_eq!(a.color, b.color, "{role:?} base moved");
555 assert_eq!(a.foreground, b.foreground, "{role:?} foreground moved");
556 assert_eq!(a.hover(), b.hover(), "{role:?} hover moved");
557 assert_eq!(a.soft(), b.soft(), "{role:?} soft moved");
558 assert_eq!(a.soft_hover(), b.soft_hover(), "{role:?} soft-hover moved");
559 }
560 assert_eq!(plain.colors.foreground, vibrant.colors.foreground);
561 assert_eq!(plain.colors.background, vibrant.colors.background);
562 assert_eq!(plain.colors.focus, vibrant.colors.focus);
563 assert_eq!(plain.colors.surface.hover(), vibrant.colors.surface.hover());
564 assert_eq!(plain.colors.field.hover(), vibrant.colors.field.hover());
565
566 let off = Theme::builder("off", base)
568 .vibrant_palette(true)
569 .vibrant_palette(false)
570 .build();
571 assert!(!off.colors.vibrant_palette());
572 assert_eq!(
573 off.colors.accent.soft_foreground(off.colors.foreground),
574 plain.colors.accent.soft_foreground(plain.colors.foreground)
575 );
576 }
577 }
578}