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