1//! A trait-based theme system for gpuikit
2//!
3//! The `Themeable` trait defines the color contract that gpuikit components use.
4//! Consumers can implement this trait for their own theme types.
5
6use gpui::{hsla, App, Global, Hsla, SharedString};
7use std::sync::Arc;
8
9/// Core theme trait that defines the color contract for UI components.
10///
11/// Implement this trait for your own theme type to customize colors.
12/// Only a small set of "primitive" colors are required - everything else
13/// has sensible defaults derived from them.
14pub trait Themeable {
15 // === Required methods (the primitives) ===
16
17 /// Primary foreground/text color
18 fn fg(&self) -> Hsla;
19
20 /// Primary background color
21 fn bg(&self) -> Hsla;
22
23 /// Surface color for cards, panels, elevated elements
24 fn surface(&self) -> Hsla;
25
26 /// Border color for dividers and boundaries
27 fn border(&self) -> Hsla;
28
29 /// Accent color for primary actions and focus states
30 fn accent(&self) -> Hsla;
31
32 // === Optional methods with defaults ===
33
34 /// Muted foreground for secondary text
35 fn fg_muted(&self) -> Hsla {
36 self.fg().opacity(0.7)
37 }
38
39 /// Disabled foreground color
40 fn fg_disabled(&self) -> Hsla {
41 self.fg().opacity(0.4)
42 }
43
44 /// Secondary surface for nested panels
45 fn surface_secondary(&self) -> Hsla {
46 self.surface()
47 }
48
49 /// Tertiary surface for deeply nested elements
50 fn surface_tertiary(&self) -> Hsla {
51 self.surface_secondary()
52 }
53
54 /// Secondary border for hover states
55 fn border_secondary(&self) -> Hsla {
56 self.border()
57 }
58
59 /// Subtle border for minimal separation
60 fn border_subtle(&self) -> Hsla {
61 self.border().opacity(0.5)
62 }
63
64 /// Focus outline color
65 fn outline(&self) -> Hsla {
66 self.accent()
67 }
68
69 /// Accent background (for tags, badges)
70 fn accent_bg(&self) -> Hsla {
71 self.accent().opacity(0.15)
72 }
73
74 /// Accent background hover state
75 fn accent_bg_hover(&self) -> Hsla {
76 self.accent().opacity(0.25)
77 }
78
79 /// Danger/error color
80 fn danger(&self) -> Hsla {
81 hsla(0.0, 0.7, 0.5, 1.0)
82 }
83
84 /// Selection highlight color
85 fn selection(&self) -> Hsla {
86 self.accent().opacity(0.3)
87 }
88
89 // === Component-specific defaults ===
90
91 fn button_bg(&self) -> Hsla {
92 self.surface()
93 }
94
95 fn button_bg_hover(&self) -> Hsla {
96 self.surface_secondary()
97 }
98
99 fn button_bg_active(&self) -> Hsla {
100 self.surface_tertiary()
101 }
102
103 fn button_border(&self) -> Hsla {
104 self.border()
105 }
106
107 fn input_bg(&self) -> Hsla {
108 self.surface()
109 }
110
111 fn input_border(&self) -> Hsla {
112 self.border()
113 }
114
115 fn input_border_hover(&self) -> Hsla {
116 self.border_secondary()
117 }
118
119 fn input_border_focused(&self) -> Hsla {
120 self.accent()
121 }
122}
123
124pub fn init(cx: &mut App) {
125 cx.set_global(GlobalTheme::default());
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum ThemeVariant {
130 #[default]
131 Dark,
132 Light,
133}
134
135/// A concrete theme implementation with stored color values.
136#[derive(Debug, Clone)]
137pub struct Theme {
138 pub name: SharedString,
139 pub variant: ThemeVariant,
140
141 // Primitives
142 fg_color: Hsla,
143 bg_color: Hsla,
144 surface_color: Hsla,
145 border_color: Hsla,
146 accent_color: Hsla,
147
148 // Overrides (None = use default from trait)
149 fg_muted_color: Option<Hsla>,
150 fg_disabled_color: Option<Hsla>,
151 surface_secondary_color: Option<Hsla>,
152 surface_tertiary_color: Option<Hsla>,
153 border_secondary_color: Option<Hsla>,
154 border_subtle_color: Option<Hsla>,
155 outline_color: Option<Hsla>,
156 accent_bg_color: Option<Hsla>,
157 accent_bg_hover_color: Option<Hsla>,
158 danger_color: Option<Hsla>,
159 selection_color: Option<Hsla>,
160 button_bg_color: Option<Hsla>,
161 button_bg_hover_color: Option<Hsla>,
162 button_bg_active_color: Option<Hsla>,
163 button_border_color: Option<Hsla>,
164 input_bg_color: Option<Hsla>,
165 input_border_color: Option<Hsla>,
166 input_border_hover_color: Option<Hsla>,
167 input_border_focused_color: Option<Hsla>,
168}
169
170impl Themeable for Theme {
171 fn fg(&self) -> Hsla {
172 self.fg_color
173 }
174 fn bg(&self) -> Hsla {
175 self.bg_color
176 }
177 fn surface(&self) -> Hsla {
178 self.surface_color
179 }
180 fn border(&self) -> Hsla {
181 self.border_color
182 }
183 fn accent(&self) -> Hsla {
184 self.accent_color
185 }
186
187 fn fg_muted(&self) -> Hsla {
188 self.fg_muted_color
189 .unwrap_or_else(|| self.fg().opacity(0.7))
190 }
191 fn fg_disabled(&self) -> Hsla {
192 self.fg_disabled_color
193 .unwrap_or_else(|| self.fg().opacity(0.4))
194 }
195 fn surface_secondary(&self) -> Hsla {
196 self.surface_secondary_color
197 .unwrap_or_else(|| self.surface())
198 }
199 fn surface_tertiary(&self) -> Hsla {
200 self.surface_tertiary_color
201 .unwrap_or_else(|| self.surface_secondary())
202 }
203 fn border_secondary(&self) -> Hsla {
204 self.border_secondary_color.unwrap_or_else(|| self.border())
205 }
206 fn border_subtle(&self) -> Hsla {
207 self.border_subtle_color
208 .unwrap_or_else(|| self.border().opacity(0.5))
209 }
210 fn outline(&self) -> Hsla {
211 self.outline_color.unwrap_or_else(|| self.accent())
212 }
213 fn accent_bg(&self) -> Hsla {
214 self.accent_bg_color
215 .unwrap_or_else(|| self.accent().opacity(0.15))
216 }
217 fn accent_bg_hover(&self) -> Hsla {
218 self.accent_bg_hover_color
219 .unwrap_or_else(|| self.accent().opacity(0.25))
220 }
221 fn danger(&self) -> Hsla {
222 self.danger_color
223 .unwrap_or_else(|| hsla(0.0, 0.7, 0.5, 1.0))
224 }
225 fn selection(&self) -> Hsla {
226 self.selection_color
227 .unwrap_or_else(|| self.accent().opacity(0.3))
228 }
229 fn button_bg(&self) -> Hsla {
230 self.button_bg_color.unwrap_or_else(|| self.surface())
231 }
232 fn button_bg_hover(&self) -> Hsla {
233 self.button_bg_hover_color
234 .unwrap_or_else(|| self.surface_secondary())
235 }
236 fn button_bg_active(&self) -> Hsla {
237 self.button_bg_active_color
238 .unwrap_or_else(|| self.surface_tertiary())
239 }
240 fn button_border(&self) -> Hsla {
241 self.button_border_color.unwrap_or_else(|| self.border())
242 }
243 fn input_bg(&self) -> Hsla {
244 self.input_bg_color.unwrap_or_else(|| self.surface())
245 }
246 fn input_border(&self) -> Hsla {
247 self.input_border_color.unwrap_or_else(|| self.border())
248 }
249 fn input_border_hover(&self) -> Hsla {
250 self.input_border_hover_color
251 .unwrap_or_else(|| self.border_secondary())
252 }
253 fn input_border_focused(&self) -> Hsla {
254 self.input_border_focused_color
255 .unwrap_or_else(|| self.accent())
256 }
257}
258
259impl Theme {
260 /// Create a new theme with just the required primitives.
261 /// All other colors will use sensible defaults.
262 pub fn new(
263 name: impl Into<SharedString>,
264 variant: ThemeVariant,
265 fg: Hsla,
266 bg: Hsla,
267 surface: Hsla,
268 border: Hsla,
269 accent: Hsla,
270 ) -> Self {
271 Theme {
272 name: name.into(),
273 variant,
274 fg_color: fg,
275 bg_color: bg,
276 surface_color: surface,
277 border_color: border,
278 accent_color: accent,
279 fg_muted_color: None,
280 fg_disabled_color: None,
281 surface_secondary_color: None,
282 surface_tertiary_color: None,
283 border_secondary_color: None,
284 border_subtle_color: None,
285 outline_color: None,
286 accent_bg_color: None,
287 accent_bg_hover_color: None,
288 danger_color: None,
289 selection_color: None,
290 button_bg_color: None,
291 button_bg_hover_color: None,
292 button_bg_active_color: None,
293 button_border_color: None,
294 input_bg_color: None,
295 input_border_color: None,
296 input_border_hover_color: None,
297 input_border_focused_color: None,
298 }
299 }
300
301 /// Create a Gruvbox Dark theme
302 pub fn gruvbox_dark() -> Self {
303 let mut theme = Theme::new(
304 "Gruvbox Dark",
305 ThemeVariant::Dark,
306 parse_hex("#ebdbb2"), // fg
307 parse_hex("#282828"), // bg
308 parse_hex("#3c3836"), // surface
309 parse_hex("#504945"), // border
310 parse_hex("#8ec07c"), // accent
311 );
312 theme.fg_muted_color = Some(parse_hex("#a89984"));
313 theme.fg_disabled_color = Some(parse_hex("#7c6f64"));
314 theme.surface_secondary_color = Some(parse_hex("#504945"));
315 theme.surface_tertiary_color = Some(parse_hex("#665c54"));
316 theme.border_secondary_color = Some(parse_hex("#7c6f64"));
317 theme.border_subtle_color = Some(parse_hex("#3c3836"));
318 theme.outline_color = Some(parse_hex("#458588"));
319 theme.danger_color = Some(parse_hex("#fb4934"));
320 theme.selection_color = Some(hsla(55.0 / 360.0, 0.56, 0.64, 0.25));
321 theme.button_bg_color = Some(parse_hex("#504945"));
322 theme.button_bg_hover_color = Some(parse_hex("#665c54"));
323 theme.button_bg_active_color = Some(parse_hex("#7c6f64"));
324 theme.button_border_color = Some(parse_hex("#7c6f64"));
325 theme.input_border_hover_color = Some(parse_hex("#665c54"));
326 theme
327 }
328
329 /// Create a Gruvbox Light theme
330 pub fn gruvbox_light() -> Self {
331 let mut theme = Theme::new(
332 "Gruvbox Light",
333 ThemeVariant::Light,
334 parse_hex("#3c3836"), // fg
335 parse_hex("#fbf1c7"), // bg
336 parse_hex("#ebdbb2"), // surface
337 parse_hex("#d5c4a1"), // border
338 parse_hex("#427b58"), // accent
339 );
340 theme.fg_muted_color = Some(parse_hex("#665c54"));
341 theme.fg_disabled_color = Some(parse_hex("#a89984"));
342 theme.surface_secondary_color = Some(parse_hex("#d5c4a1"));
343 theme.surface_tertiary_color = Some(parse_hex("#bdae93"));
344 theme.border_secondary_color = Some(parse_hex("#a89984"));
345 theme.border_subtle_color = Some(parse_hex("#ebdbb2"));
346 theme.outline_color = Some(parse_hex("#076678"));
347 theme.danger_color = Some(parse_hex("#cc241d"));
348 theme.selection_color = Some(hsla(48.0 / 360.0, 0.87, 0.61, 0.15));
349 theme.button_bg_color = Some(parse_hex("#ebdbb2"));
350 theme.button_bg_hover_color = Some(parse_hex("#d5c4a1"));
351 theme.button_bg_active_color = Some(parse_hex("#bdae93"));
352 theme.button_border_color = Some(parse_hex("#a89984"));
353 theme.input_border_hover_color = Some(parse_hex("#bdae93"));
354 theme
355 }
356
357 pub fn get_global(cx: &App) -> &Arc<Theme> {
358 &cx.global::<GlobalTheme>().0
359 }
360}
361
362impl Default for Theme {
363 fn default() -> Self {
364 Theme::gruvbox_dark()
365 }
366}
367
368#[derive(Clone, Debug)]
369pub struct GlobalTheme(pub Arc<Theme>);
370
371impl Global for GlobalTheme {}
372
373impl Default for GlobalTheme {
374 fn default() -> Self {
375 GlobalTheme(Arc::new(Theme::default()))
376 }
377}
378
379/// Trait for accessing the current theme from an App context
380pub trait ActiveTheme {
381 fn theme(&self) -> &Arc<Theme>;
382}
383
384impl ActiveTheme for App {
385 fn theme(&self) -> &Arc<Theme> {
386 &self.global::<GlobalTheme>().0
387 }
388}
389
390fn parse_hex(hex: &str) -> Hsla {
391 let hex = hex.trim_start_matches('#');
392
393 let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
394 let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
395 let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
396
397 let r = r as f32 / 255.0;
398 let g = g as f32 / 255.0;
399 let b = b as f32 / 255.0;
400
401 let max = r.max(g).max(b);
402 let min = r.min(g).min(b);
403 let delta = max - min;
404
405 let lightness = (max + min) / 2.0;
406
407 let saturation = if delta == 0.0 {
408 0.0
409 } else {
410 delta / (1.0 - (2.0 * lightness - 1.0).abs())
411 };
412
413 let hue = if delta == 0.0 {
414 0.0
415 } else if max == r {
416 60.0 * (((g - b) / delta) % 6.0)
417 } else if max == g {
418 60.0 * ((b - r) / delta + 2.0)
419 } else {
420 60.0 * ((r - g) / delta + 4.0)
421 };
422
423 let hue = if hue < 0.0 { hue + 360.0 } else { hue };
424
425 gpui::hsla(hue / 360.0, saturation, lightness, 1.0)
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn test_default_theme() {
434 let theme = Theme::default();
435 assert_eq!(theme.name, SharedString::from("Gruvbox Dark"));
436 assert_eq!(theme.variant, ThemeVariant::Dark);
437 }
438
439 #[test]
440 fn test_minimal_theme() {
441 // Create a theme with just primitives - all else uses defaults
442 let theme = Theme::new(
443 "Minimal",
444 ThemeVariant::Dark,
445 parse_hex("#ffffff"),
446 parse_hex("#000000"),
447 parse_hex("#111111"),
448 parse_hex("#333333"),
449 parse_hex("#0066cc"),
450 );
451
452 // Derived values should work
453 assert_eq!(theme.button_bg(), theme.surface());
454 assert_eq!(theme.input_border_focused(), theme.accent());
455 }
456
457 #[test]
458 fn test_hex_parsing() {
459 let color = parse_hex("#ffffff");
460 assert_eq!(color.l, 1.0);
461
462 let color = parse_hex("#000000");
463 assert_eq!(color.l, 0.0);
464 }
465}