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 field(mut self, background: Hsla, foreground: Hsla) -> Self {
314 self.theme.colors.field.background = background;
315 self.theme.colors.field.foreground = foreground;
316 self
317 }
318
319 pub fn field_placeholder(mut self, c: Hsla) -> Self {
320 self.theme.colors.field.placeholder = c;
321 self
322 }
323
324 pub fn field_border(mut self, c: Hsla) -> Self {
325 self.theme.colors.field.border = c;
326 self
327 }
328
329 pub fn build(self) -> Theme {
330 self.theme
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use herogpui_core::{mix_oklab, oklch, with_alpha};
338
339 #[test]
340 fn the_builder_overrides_the_interactive_cursor_and_nothing_else() {
341 let base = Theme::light();
342 assert_eq!(
343 base.layout.cursor_interactive,
344 gpui::CursorStyle::PointingHand
345 );
346
347 let theme = Theme::builder("arrow", base.clone())
348 .cursor_interactive(gpui::CursorStyle::Arrow)
349 .build();
350
351 assert_eq!(theme.layout.cursor_interactive, gpui::CursorStyle::Arrow);
352 assert_eq!(theme.layout.radius, base.layout.radius);
353 assert!(
354 (theme.layout.disabled_opacity - base.layout.disabled_opacity).abs() < f32::EPSILON
355 );
356 assert_eq!(theme.colors.background, base.colors.background);
357 }
358
359 #[test]
360 fn customisation_tokens_flow_through_the_builder_and_clamp() {
361 let theme = Theme::builder("custom", Theme::light())
362 .tabs_hover_opacity(2.0)
363 .tooltip_cooldown_ms(250)
364 .long_press_ms(350)
365 .hover_fade_ms(0)
366 .tooltip_delay_ms(50)
367 .tooltip_close_delay_ms(75)
368 .build();
369 assert!((theme.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
370 assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
371 assert_eq!(theme.layout.long_press_ms, 350);
372 assert_eq!(theme.layout.hover_fade_ms, 0);
373 assert_eq!(theme.layout.tooltip_delay_ms, 50);
374 assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
375
376 let nan = Theme::builder("nan", Theme::light())
377 .tabs_hover_opacity(f32::NAN)
378 .build();
379 assert!((nan.layout.tabs_hover_opacity - 0.7).abs() < f32::EPSILON);
380 }
381
382 #[test]
383 fn overriding_foreground_recomputes_scrollbar_without_changing_other_tokens() {
384 let base = Theme::light();
385 let foreground = oklch(0.30, 0.05, 120.0);
386 let background = oklch(0.90, 0.01, 286.0);
387 let unchanged = (
388 base.colors.muted,
389 base.colors.border,
390 base.colors.separator,
391 base.colors.focus,
392 base.layout.radius,
393 base.layout.disabled_opacity,
394 );
395 let theme = Theme::builder("brand", base)
396 .background(background)
397 .foreground(foreground)
398 .build();
399
400 assert_eq!(
401 (
402 theme.colors.scrollbar,
403 theme.colors.background,
404 theme.colors.muted,
405 theme.colors.border,
406 theme.colors.separator,
407 theme.colors.focus,
408 theme.layout.radius,
409 theme.layout.disabled_opacity,
410 ),
411 (
412 with_alpha(foreground, 0.15),
413 background,
414 unchanged.0,
415 unchanged.1,
416 unchanged.2,
417 unchanged.3,
418 unchanged.4,
419 unchanged.5,
420 )
421 );
422 }
423
424 #[test]
425 fn overriding_a_role_keeps_its_soft_semantics() {
426 let theme = Theme::builder("brand", Theme::light())
429 .role("success", oklch(0.55, 0.18, 145.0), oklch(1.0, 0.0, 0.0))
430 .build();
431 assert!((theme.colors.success.soft().a - 0.15).abs() < 1e-4);
432 assert!((theme.colors.success.soft_hover().a - 0.20).abs() < 1e-4);
433 assert_eq!(
434 theme
435 .colors
436 .success
437 .soft_foreground(theme.colors.foreground),
438 mix_oklab(
439 theme.colors.success.color,
440 theme.colors.foreground,
441 60.0 / 140.0
442 )
443 );
444 }
445
446 #[test]
447 fn a_default_role_override_keeps_the_half_strength_soft() {
448 let theme = Theme::builder("brand", Theme::light())
449 .role(
450 "default",
451 oklch(0.90, 0.01, 286.0),
452 oklch(0.20, 0.01, 286.0),
453 )
454 .build();
455 assert!((theme.colors.default.soft().a - 0.50).abs() < 1e-4);
456 assert!((theme.colors.default.soft_hover().a - 0.60).abs() < 1e-4);
457 assert_eq!(
458 theme
459 .colors
460 .default
461 .soft_foreground(theme.colors.foreground),
462 theme.colors.default.foreground
463 );
464 }
465
466 #[test]
467 fn the_vibrant_palette_reweights_only_the_soft_foregrounds() {
468 for base in [Theme::light(), Theme::dark()] {
471 let plain = Theme::builder("plain", base.clone()).build();
472 let vibrant = Theme::builder("vibrant", base.clone())
473 .vibrant_palette(true)
474 .build();
475 assert!(!plain.colors.vibrant_palette());
476 assert!(vibrant.colors.vibrant_palette());
477
478 for role in ["accent", "success", "warning", "danger"] {
479 let r = vibrant.colors.role(role);
480 assert_eq!(
481 r.soft_foreground(vibrant.colors.foreground),
482 mix_oklab(r.color, vibrant.colors.foreground, 0.08),
483 "{role} soft-foreground is not the 92/8 vibrant mix"
484 );
485 assert_ne!(
486 r.soft_foreground(vibrant.colors.foreground),
487 plain
488 .colors
489 .role(role)
490 .soft_foreground(plain.colors.foreground),
491 "{role} soft-foreground did not move"
492 );
493 }
494
495 assert_eq!(
498 vibrant
499 .colors
500 .default
501 .soft_foreground(vibrant.colors.foreground),
502 plain.colors.default.foreground
503 );
504
505 for role in ["default", "accent", "success", "warning", "danger"] {
507 let (a, b) = (plain.colors.role(role), vibrant.colors.role(role));
508 assert_eq!(a.color, b.color, "{role} base moved");
509 assert_eq!(a.foreground, b.foreground, "{role} foreground moved");
510 assert_eq!(a.hover(), b.hover(), "{role} hover moved");
511 assert_eq!(a.soft(), b.soft(), "{role} soft moved");
512 assert_eq!(a.soft_hover(), b.soft_hover(), "{role} soft-hover moved");
513 }
514 assert_eq!(plain.colors.foreground, vibrant.colors.foreground);
515 assert_eq!(plain.colors.background, vibrant.colors.background);
516 assert_eq!(plain.colors.focus, vibrant.colors.focus);
517 assert_eq!(plain.colors.surface.hover(), vibrant.colors.surface.hover());
518 assert_eq!(plain.colors.field.hover(), vibrant.colors.field.hover());
519
520 let off = Theme::builder("off", base)
522 .vibrant_palette(true)
523 .vibrant_palette(false)
524 .build();
525 assert!(!off.colors.vibrant_palette());
526 assert_eq!(
527 off.colors.accent.soft_foreground(off.colors.foreground),
528 plain.colors.accent.soft_foreground(plain.colors.foreground)
529 );
530 }
531 }
532}