1use gpui::{Hsla, Pixels, SharedString, WindowAppearance};
7
8use crate::layout::LayoutTheme;
9use crate::semantic::{SurfaceColor, ThemeColors};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
15pub enum Appearance {
16 Light,
17 Dark,
18}
19
20impl From<WindowAppearance> for Appearance {
29 fn from(appearance: WindowAppearance) -> Self {
30 match appearance {
31 WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
32 WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
33 }
34 }
35}
36
37#[derive(Clone, Debug)]
39pub struct Theme {
40 pub id: SharedString,
41 pub appearance: Appearance,
42 pub colors: ThemeColors,
43 pub layout: LayoutTheme,
44 pub components: crate::ComponentThemes,
45}
46
47impl Theme {
48 pub fn light() -> Self {
50 Self {
51 id: "light".into(),
52 appearance: Appearance::Light,
53 colors: ThemeColors::light(),
54 layout: LayoutTheme::light(),
55 components: crate::ComponentThemes::default(),
56 }
57 }
58
59 pub fn dark() -> Self {
61 Self {
62 id: "dark".into(),
63 appearance: Appearance::Dark,
64 colors: ThemeColors::dark(),
65 layout: LayoutTheme::dark(),
66 components: crate::ComponentThemes::default(),
67 }
68 }
69
70 pub fn builder(id: impl Into<SharedString>, base: Theme) -> ThemeBuilder {
77 ThemeBuilder { theme: base }.id(id)
78 }
79
80 pub fn is_dark(&self) -> bool {
81 self.appearance == Appearance::Dark
82 }
83}
84
85pub struct ThemeBuilder {
87 theme: Theme,
88}
89
90impl ThemeBuilder {
91 pub fn components(mut self, components: crate::ComponentThemes) -> Self {
93 self.theme.components = components;
94 self
95 }
96
97 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
98 self.theme.id = id.into();
99 self
100 }
101
102 pub fn appearance(mut self, appearance: Appearance) -> Self {
103 self.theme.appearance = appearance;
104 self
105 }
106
107 pub fn radius(mut self, radius: Pixels) -> Self {
112 self.theme.layout.radius = radius;
113 self.theme.layout.field_radius = radius * 1.5;
114 self
115 }
116
117 pub fn field_radius(mut self, radius: Pixels) -> Self {
118 self.theme.layout.field_radius = radius;
119 self
120 }
121
122 pub fn border_width(mut self, width: Pixels) -> Self {
123 self.theme.layout.border_width = width;
124 self
125 }
126
127 pub fn disabled_opacity(mut self, v: f32) -> Self {
128 self.theme.layout.disabled_opacity = v;
129 self
130 }
131
132 pub fn cursor_interactive(mut self, cursor: gpui::CursorStyle) -> Self {
135 self.theme.layout.cursor_interactive = cursor;
136 self
137 }
138
139 pub fn tabs_hover_opacity(mut self, v: f32) -> Self {
142 self.theme.layout.tabs_hover_opacity = if v.is_finite() {
143 v.clamp(0.0, 1.0)
144 } else {
145 0.7
146 };
147 self
148 }
149
150 pub fn tooltip_cooldown_ms(mut self, ms: u64) -> Self {
154 self.theme.layout.tooltip_cooldown_ms = ms;
155 self
156 }
157
158 pub fn long_press_ms(mut self, ms: u64) -> Self {
160 self.theme.layout.long_press_ms = ms;
161 self
162 }
163
164 pub fn hover_fade_ms(mut self, ms: u64) -> Self {
167 self.theme.layout.hover_fade_ms = ms;
168 self
169 }
170
171 pub fn tooltip_delay_ms(mut self, ms: u64) -> Self {
173 self.theme.layout.tooltip_delay_ms = ms;
174 self
175 }
176
177 pub fn tooltip_close_delay_ms(mut self, ms: u64) -> Self {
179 self.theme.layout.tooltip_close_delay_ms = ms;
180 self
181 }
182
183 pub fn background(mut self, c: Hsla) -> Self {
186 self.theme.colors.background = c;
187 self
188 }
189
190 pub fn foreground(mut self, c: Hsla) -> Self {
191 self.theme.colors.foreground = c;
192 self.theme.colors.scrollbar = herogpui_core::with_alpha(c, 0.15);
193 self
194 }
195
196 pub fn muted(mut self, c: Hsla) -> Self {
197 self.theme.colors.muted = c;
198 self
199 }
200
201 pub fn border(mut self, c: Hsla) -> Self {
202 self.theme.colors.border = c;
203 self
204 }
205
206 pub fn separator(mut self, c: Hsla) -> Self {
208 self.theme.colors.separator = c;
209 self
210 }
211
212 pub fn focus(mut self, c: Hsla) -> Self {
213 self.theme.colors.focus = c;
214 self
215 }
216
217 pub fn link(mut self, c: Hsla) -> Self {
218 self.theme.colors.link = c;
219 self
220 }
221
222 pub fn backdrop(mut self, c: Hsla) -> Self {
223 self.theme.colors.backdrop = c;
224 self
225 }
226
227 pub fn surface(mut self, background: Hsla, foreground: Hsla) -> Self {
230 self.theme.colors.surface = SurfaceColor {
231 background,
232 foreground,
233 };
234 self
235 }
236
237 pub fn surface_levels(mut self, secondary: Hsla, tertiary: Hsla) -> Self {
238 self.theme.colors.surface_secondary = secondary;
239 self.theme.colors.surface_tertiary = tertiary;
240 self
241 }
242
243 pub fn overlay(mut self, background: Hsla, foreground: Hsla) -> Self {
244 self.theme.colors.overlay = SurfaceColor {
245 background,
246 foreground,
247 };
248 self
249 }
250
251 pub fn segment(mut self, background: Hsla, foreground: Hsla) -> Self {
252 self.theme.colors.segment = SurfaceColor {
253 background,
254 foreground,
255 };
256 self
257 }
258
259 pub fn role(mut self, name: &str, color: Hsla, foreground: Hsla) -> Self {
266 match name {
267 "default" => {
268 self.theme.colors.default.color = color;
269 self.theme.colors.default.foreground = foreground;
270 self.theme.colors.field.background = color;
271 }
272 "success" => {
273 self.theme.colors.success.color = color;
274 self.theme.colors.success.foreground = foreground;
275 }
276 "warning" => {
277 self.theme.colors.warning.color = color;
278 self.theme.colors.warning.foreground = foreground;
279 }
280 "danger" => {
281 self.theme.colors.danger.color = color;
282 self.theme.colors.danger.foreground = foreground;
283 }
284 _ => {
285 self.theme.colors.accent.color = color;
286 self.theme.colors.accent.foreground = foreground;
287 self.theme.colors.focus = color;
288 }
289 }
290 self
291 }
292
293 pub fn vibrant_palette(mut self, vibrant: bool) -> Self {
300 self.theme.colors.set_vibrant_palette(vibrant);
301 self
302 }
303
304 pub fn accent(self, color: Hsla) -> Self {
307 let fg = herogpui_core::readable_color(color);
308 self.role("accent", color, fg)
309 }
310
311 pub fn role_hover(mut self, name: &str, hover: Hsla) -> Self {
323 let role = match name {
324 "default" => &mut self.theme.colors.default,
325 "success" => &mut self.theme.colors.success,
326 "warning" => &mut self.theme.colors.warning,
327 "danger" => &mut self.theme.colors.danger,
328 _ => &mut self.theme.colors.accent,
329 };
330 *role = role.with_hover(hover);
331 self
332 }
333
334 pub fn accent_hover(self, hover: Hsla) -> Self {
337 self.role_hover("accent", hover)
338 }
339
340 pub fn field(mut self, background: Hsla, foreground: Hsla) -> Self {
343 self.theme.colors.field.background = background;
344 self.theme.colors.field.foreground = foreground;
345 self
346 }
347
348 pub fn field_placeholder(mut self, c: Hsla) -> Self {
349 self.theme.colors.field.placeholder = c;
350 self
351 }
352
353 pub fn field_border(mut self, c: Hsla) -> Self {
354 self.theme.colors.field.border = c;
355 self
356 }
357
358 pub fn build(self) -> Theme {
359 self.theme
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use herogpui_core::{mix_oklab, oklch, with_alpha};
367
368 #[test]
369 fn the_builder_overrides_the_interactive_cursor_and_nothing_else() {
370 let base = Theme::light();
371 assert_eq!(
372 base.layout.cursor_interactive,
373 gpui::CursorStyle::PointingHand
374 );
375
376 let theme = Theme::builder("arrow", base.clone())
377 .cursor_interactive(gpui::CursorStyle::Arrow)
378 .build();
379
380 assert_eq!(theme.layout.cursor_interactive, gpui::CursorStyle::Arrow);
381 assert_eq!(theme.layout.radius, base.layout.radius);
382 assert!(
383 (theme.layout.disabled_opacity - base.layout.disabled_opacity).abs() < f32::EPSILON
384 );
385 assert_eq!(theme.colors.background, base.colors.background);
386 }
387
388 #[test]
389 fn customisation_tokens_flow_through_the_builder_and_clamp() {
390 let theme = Theme::builder("custom", Theme::light())
391 .tabs_hover_opacity(2.0)
392 .tooltip_cooldown_ms(250)
393 .long_press_ms(350)
394 .hover_fade_ms(0)
395 .tooltip_delay_ms(50)
396 .tooltip_close_delay_ms(75)
397 .build();
398 assert!((theme.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
399 assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
400 assert_eq!(theme.layout.long_press_ms, 350);
401 assert_eq!(theme.layout.hover_fade_ms, 0);
402 assert_eq!(theme.layout.tooltip_delay_ms, 50);
403 assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
404
405 let nan = Theme::builder("nan", Theme::light())
406 .tabs_hover_opacity(f32::NAN)
407 .build();
408 assert!((nan.layout.tabs_hover_opacity - 0.7).abs() < f32::EPSILON);
409 }
410
411 #[test]
412 fn overriding_foreground_recomputes_scrollbar_without_changing_other_tokens() {
413 let base = Theme::light();
414 let foreground = oklch(0.30, 0.05, 120.0);
415 let background = oklch(0.90, 0.01, 286.0);
416 let unchanged = (
417 base.colors.muted,
418 base.colors.border,
419 base.colors.separator,
420 base.colors.focus,
421 base.layout.radius,
422 base.layout.disabled_opacity,
423 );
424 let theme = Theme::builder("brand", base)
425 .background(background)
426 .foreground(foreground)
427 .build();
428
429 assert_eq!(
430 (
431 theme.colors.scrollbar,
432 theme.colors.background,
433 theme.colors.muted,
434 theme.colors.border,
435 theme.colors.separator,
436 theme.colors.focus,
437 theme.layout.radius,
438 theme.layout.disabled_opacity,
439 ),
440 (
441 with_alpha(foreground, 0.15),
442 background,
443 unchanged.0,
444 unchanged.1,
445 unchanged.2,
446 unchanged.3,
447 unchanged.4,
448 unchanged.5,
449 )
450 );
451 }
452
453 #[test]
454 fn overriding_a_role_keeps_its_soft_semantics() {
455 let theme = Theme::builder("brand", Theme::light())
458 .role("success", oklch(0.55, 0.18, 145.0), oklch(1.0, 0.0, 0.0))
459 .build();
460 assert!((theme.colors.success.soft().a - 0.15).abs() < 1e-4);
461 assert!((theme.colors.success.soft_hover().a - 0.20).abs() < 1e-4);
462 assert_eq!(
463 theme
464 .colors
465 .success
466 .soft_foreground(theme.colors.foreground),
467 mix_oklab(
468 theme.colors.success.color,
469 theme.colors.foreground,
470 60.0 / 140.0
471 )
472 );
473 }
474
475 #[test]
476 fn a_default_role_override_keeps_the_half_strength_soft() {
477 let theme = Theme::builder("brand", Theme::light())
478 .role(
479 "default",
480 oklch(0.90, 0.01, 286.0),
481 oklch(0.20, 0.01, 286.0),
482 )
483 .build();
484 assert!((theme.colors.default.soft().a - 0.50).abs() < 1e-4);
485 assert!((theme.colors.default.soft_hover().a - 0.60).abs() < 1e-4);
486 assert_eq!(
487 theme
488 .colors
489 .default
490 .soft_foreground(theme.colors.foreground),
491 theme.colors.default.foreground
492 );
493 }
494
495 #[test]
496 fn the_vibrant_palette_reweights_only_the_soft_foregrounds() {
497 for base in [Theme::light(), Theme::dark()] {
500 let plain = Theme::builder("plain", base.clone()).build();
501 let vibrant = Theme::builder("vibrant", base.clone())
502 .vibrant_palette(true)
503 .build();
504 assert!(!plain.colors.vibrant_palette());
505 assert!(vibrant.colors.vibrant_palette());
506
507 for role in ["accent", "success", "warning", "danger"] {
508 let r = vibrant.colors.role(role);
509 assert_eq!(
510 r.soft_foreground(vibrant.colors.foreground),
511 mix_oklab(r.color, vibrant.colors.foreground, 0.08),
512 "{role} soft-foreground is not the 92/8 vibrant mix"
513 );
514 assert_ne!(
515 r.soft_foreground(vibrant.colors.foreground),
516 plain
517 .colors
518 .role(role)
519 .soft_foreground(plain.colors.foreground),
520 "{role} soft-foreground did not move"
521 );
522 }
523
524 assert_eq!(
527 vibrant
528 .colors
529 .default
530 .soft_foreground(vibrant.colors.foreground),
531 plain.colors.default.foreground
532 );
533
534 for role in ["default", "accent", "success", "warning", "danger"] {
536 let (a, b) = (plain.colors.role(role), vibrant.colors.role(role));
537 assert_eq!(a.color, b.color, "{role} base moved");
538 assert_eq!(a.foreground, b.foreground, "{role} foreground moved");
539 assert_eq!(a.hover(), b.hover(), "{role} hover moved");
540 assert_eq!(a.soft(), b.soft(), "{role} soft moved");
541 assert_eq!(a.soft_hover(), b.soft_hover(), "{role} soft-hover moved");
542 }
543 assert_eq!(plain.colors.foreground, vibrant.colors.foreground);
544 assert_eq!(plain.colors.background, vibrant.colors.background);
545 assert_eq!(plain.colors.focus, vibrant.colors.focus);
546 assert_eq!(plain.colors.surface.hover(), vibrant.colors.surface.hover());
547 assert_eq!(plain.colors.field.hover(), vibrant.colors.field.hover());
548
549 let off = Theme::builder("off", base)
551 .vibrant_palette(true)
552 .vibrant_palette(false)
553 .build();
554 assert!(!off.colors.vibrant_palette());
555 assert_eq!(
556 off.colors.accent.soft_foreground(off.colors.foreground),
557 plain.colors.accent.soft_foreground(plain.colors.foreground)
558 );
559 }
560 }
561}