1use std::collections::HashMap;
7
8use gpui::{Div, Hsla, Pixels, SharedString, StyleRefinement, Styled};
9
10use crate::ThemeColors;
11use herogpui_core::{Color, FieldVariant, Size, Variant};
12
13#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum ComponentColor {
16 Literal(Hsla),
17 Background,
18 Foreground,
19 Muted,
20 Surface,
21 SurfaceForeground,
22 SurfaceSecondary,
23 SurfaceTertiary,
24 Border,
25 FieldBackground,
26 FieldForeground,
27 FieldPlaceholder,
28 Role(Color),
29 RoleForeground(Color),
30 RoleHover(Color),
31 RoleSoft(Color),
32}
33
34impl From<Hsla> for ComponentColor {
35 fn from(color: Hsla) -> Self {
36 Self::Literal(color)
37 }
38}
39
40impl ComponentColor {
41 pub fn resolve(self, colors: &ThemeColors) -> Hsla {
42 let role = |role| match role {
43 Color::Default => &colors.default,
44 Color::Accent => &colors.accent,
45 Color::Success => &colors.success,
46 Color::Warning => &colors.warning,
47 Color::Danger => &colors.danger,
48 };
49 match self {
50 Self::Literal(color) => color,
51 Self::Background => colors.background,
52 Self::Foreground => colors.foreground,
53 Self::Muted => colors.muted,
54 Self::Surface => colors.surface.background,
55 Self::SurfaceForeground => colors.surface.foreground,
56 Self::SurfaceSecondary => colors.surface_secondary,
57 Self::SurfaceTertiary => colors.surface_tertiary,
58 Self::Border => colors.border,
59 Self::FieldBackground => colors.field.background,
60 Self::FieldForeground => colors.field.foreground,
61 Self::FieldPlaceholder => colors.field.placeholder,
62 Self::Role(color) => role(color).color,
63 Self::RoleForeground(color) => role(color).foreground,
64 Self::RoleHover(color) => role(color).hover(),
65 Self::RoleSoft(color) => role(color).soft(),
66 }
67 }
68}
69
70pub trait ComponentStyle: Clone + Default {
72 fn refine(&mut self, overlay: &Self);
74}
75
76#[derive(Clone, Debug)]
78#[non_exhaustive]
79pub struct ComponentTheme<T> {
80 pub defaults: T,
81 pub recipes: HashMap<SharedString, T>,
82}
83
84impl<T: Default> Default for ComponentTheme<T> {
85 fn default() -> Self {
86 Self::new(T::default())
87 }
88}
89
90impl<T> ComponentTheme<T> {
91 pub fn new(defaults: T) -> Self {
92 Self {
93 defaults,
94 recipes: HashMap::new(),
95 }
96 }
97
98 pub fn defaults(mut self, defaults: T) -> Self {
99 self.defaults = defaults;
100 self
101 }
102
103 pub fn recipe(mut self, name: impl Into<SharedString>, style: T) -> Self {
104 self.recipes.insert(name.into(), style);
105 self
106 }
107}
108
109impl<T: ComponentStyle> ComponentTheme<T> {
110 pub fn resolve(&self, names: &[SharedString]) -> T {
113 let mut style = self.defaults.clone();
114 for name in names {
115 if let Some(recipe) = self.recipes.get(name) {
116 style.refine(recipe);
117 }
118 }
119 style
120 }
121}
122
123macro_rules! component_style {
124 ($(#[$meta:meta])* $name:ident { $($(#[$field_meta:meta])* $field:ident: $ty:ty),* $(,)? }) => {
125 $(#[$meta])*
126 #[derive(Clone, Debug, Default)]
127 #[non_exhaustive]
128 pub struct $name {
129 $($(#[$field_meta])* pub $field: Option<$ty>,)*
130 }
131 impl $name {
132 $(
133 $(#[$field_meta])*
134 pub fn $field(mut self, value: impl Into<$ty>) -> Self {
135 self.$field = Some(value.into());
136 self
137 }
138 )*
139 }
140 impl ComponentStyle for $name {
141 fn refine(&mut self, overlay: &Self) {
142 $(if overlay.$field.is_some() {
143 self.$field = overlay.$field.clone();
144 })*
145 }
146 }
147 };
148}
149
150component_style! {
151 SliderStyle { radius: Pixels }
153}
154component_style! {
155 SwitchStyle { radius: Pixels }
157}
158component_style! {
159 SelectStyle {
161 variant: FieldVariant,
162 height: Pixels,
163 padding_x: Pixels,
164 padding_y: Pixels,
172 trigger_text_size: Pixels,
173 row_height: Pixels,
174 row_padding_x: Pixels,
175 row_padding_y: Pixels,
176 row_text_size: Pixels,
177 panel_padding: Pixels,
178 radius: Pixels,
179 row_hover_bg: ComponentColor,
180 is_bare: bool,
181 }
182}
183component_style! {
184 MenuStyle {
186 panel_min_width: Pixels,
187 panel_max_width: Pixels,
188 panel_max_height: Pixels,
189 panel_padding: Pixels,
190 panel_gap: Pixels,
191 row_height: Pixels,
192 row_padding_x: Pixels,
193 row_padding_y: Pixels,
194 row_text_size: Pixels,
195 row_gap: Pixels,
196 row_hover_bg: ComponentColor,
197 row_hover_foreground: ComponentColor,
198 radius: Pixels,
199 separator_inset: Pixels,
207 separator_thickness: Pixels,
212 animate_entry: bool,
213 }
214}
215component_style! {
216 TextFieldStyle {
219 variant: FieldVariant,
220 height: Pixels,
221 padding_x: Pixels,
222 text_size: Pixels,
223 radius: Pixels,
224 is_bare: bool,
225 focus_ring: bool,
226 background: ComponentColor,
227 foreground: ComponentColor,
228 placeholder: ComponentColor,
229 }
230}
231
232#[derive(Clone, Debug, Default)]
235#[non_exhaustive]
236pub struct ButtonStyle {
237 pub variant: Option<Variant>,
238 pub size: Option<Size>,
239 pub radius: Option<Pixels>,
240 pub background: Option<ComponentColor>,
241 pub foreground: Option<ComponentColor>,
242 pub hover_bg: Option<ComponentColor>,
243 pub hover_foreground: Option<ComponentColor>,
244 pub pressed_bg: Option<ComponentColor>,
245 pub pressed_foreground: Option<ComponentColor>,
246 pub disabled_foreground: Option<ComponentColor>,
247 pub style: Option<StyleRefinement>,
248}
249
250impl ButtonStyle {
251 pub fn variant(mut self, variant: Variant) -> Self {
260 self.variant = Some(variant);
261 self
262 }
263
264 pub fn size(mut self, size: Size) -> Self {
265 self.size = Some(size);
266 self
267 }
268
269 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
270 self.radius = Some(radius.into());
271 self
272 }
273
274 pub fn background(mut self, color: impl Into<ComponentColor>) -> Self {
275 self.background = Some(color.into());
276 self
277 }
278
279 pub fn foreground(mut self, color: impl Into<ComponentColor>) -> Self {
280 self.foreground = Some(color.into());
281 self
282 }
283
284 pub fn hover_bg(mut self, color: impl Into<ComponentColor>) -> Self {
285 self.hover_bg = Some(color.into());
286 self
287 }
288
289 pub fn hover_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
290 self.hover_foreground = Some(color.into());
291 self
292 }
293
294 pub fn pressed_bg(mut self, color: impl Into<ComponentColor>) -> Self {
295 self.pressed_bg = Some(color.into());
296 self
297 }
298
299 pub fn pressed_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
300 self.pressed_foreground = Some(color.into());
301 self
302 }
303
304 pub fn disabled_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
305 self.disabled_foreground = Some(color.into());
306 self
307 }
308
309 pub fn style(mut self, style: impl FnOnce(Div) -> Div) -> Self {
312 self.style = Some(style(gpui::div()).style().clone());
313 self
314 }
315}
316
317impl ComponentStyle for ButtonStyle {
318 fn refine(&mut self, overlay: &Self) {
319 macro_rules! fields {
320 ($($field:ident),*) => {
321 $(if overlay.$field.is_some() {
322 self.$field = overlay.$field;
323 })*
324 };
325 }
326 fields!(
327 variant,
328 size,
329 radius,
330 background,
331 foreground,
332 hover_bg,
333 hover_foreground,
334 pressed_bg,
335 pressed_foreground,
336 disabled_foreground
337 );
338 if let Some(style) = &overlay.style {
339 use gpui::Refineable as _;
340 self.style
341 .get_or_insert_with(StyleRefinement::default)
342 .refine(style);
343 }
344 }
345}
346
347#[derive(Clone, Debug, Default)]
349#[non_exhaustive]
350pub struct ComponentThemes {
351 pub slider: ComponentTheme<SliderStyle>,
352 pub switch: ComponentTheme<SwitchStyle>,
353 pub select: ComponentTheme<SelectStyle>,
354 pub menu: ComponentTheme<MenuStyle>,
355 pub button: ComponentTheme<ButtonStyle>,
356 pub text_field: ComponentTheme<TextFieldStyle>,
357}
358
359impl ComponentThemes {
360 pub fn slider(mut self, slider: ComponentTheme<SliderStyle>) -> Self {
361 self.slider = slider;
362 self
363 }
364
365 pub fn switch(mut self, switch: ComponentTheme<SwitchStyle>) -> Self {
366 self.switch = switch;
367 self
368 }
369
370 pub fn select(mut self, select: ComponentTheme<SelectStyle>) -> Self {
371 self.select = select;
372 self
373 }
374
375 pub fn menu(mut self, menu: ComponentTheme<MenuStyle>) -> Self {
376 self.menu = menu;
377 self
378 }
379
380 pub fn button(mut self, button: ComponentTheme<ButtonStyle>) -> Self {
381 self.button = button;
382 self
383 }
384
385 pub fn text_field(mut self, text_field: ComponentTheme<TextFieldStyle>) -> Self {
386 self.text_field = text_field;
387 self
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::ThemeColors;
395 use gpui::px;
396 use herogpui_core::oklch;
397
398 #[test]
399 fn empty_styles_resolve_to_stock_none_fields() {
400 let themes = ComponentThemes::default();
401 let slider = themes.slider.resolve(&[]);
402 assert_eq!(slider.radius, None);
403 let button = themes.button.resolve(&[]);
404 assert_eq!(button.variant, None);
405 assert!(button.style.is_none());
406 }
407
408 #[test]
409 fn recipes_refine_in_order_and_missing_names_are_ignored() {
410 let theme = ComponentTheme::new(MenuStyle::default().panel_gap(px(2.)))
411 .recipe(
412 "compact",
413 MenuStyle::default().row_height(px(28.)).panel_gap(px(0.)),
414 )
415 .recipe(
416 "accent",
417 MenuStyle::default().row_hover_bg(ComponentColor::Role(Color::Accent)),
418 );
419 let missing = theme.resolve(&["missing".into()]);
420 assert_eq!(missing.panel_gap, Some(px(2.)));
421 assert_eq!(missing.row_height, None);
422
423 let stacked = theme.resolve(&["compact".into(), "accent".into()]);
424 assert_eq!(stacked.panel_gap, Some(px(0.)));
425 assert_eq!(stacked.row_height, Some(px(28.)));
426 assert_eq!(
427 stacked.row_hover_bg,
428 Some(ComponentColor::Role(Color::Accent))
429 );
430 }
431
432 #[test]
433 fn text_field_theme_can_configure_focus_ring_visibility() {
434 let theme = ComponentTheme::new(TextFieldStyle::default().focus_ring(false))
435 .recipe("ring", TextFieldStyle::default().focus_ring(true));
436 assert_eq!(theme.resolve(&[]).focus_ring, Some(false));
437 assert_eq!(theme.resolve(&["ring".into()]).focus_ring, Some(true));
438 }
439
440 #[test]
441 fn button_style_merges_refinements() {
442 let base = ButtonStyle::default()
443 .radius(px(8.))
444 .style(|el| el.h(px(36.)).px(px(16.)));
445 let overlay = ButtonStyle::default()
446 .hover_bg(ComponentColor::Muted)
447 .style(|el| el.h(px(28.)));
448 let mut merged = base;
449 merged.refine(&overlay);
450 assert_eq!(merged.radius, Some(px(8.)));
451 assert_eq!(merged.hover_bg, Some(ComponentColor::Muted));
452 let boxed = Some(Box::new(merged.style.unwrap()));
453 let size = {
454 match boxed.as_ref().unwrap().size.height {
456 Some(gpui::Length::Definite(gpui::DefiniteLength::Absolute(
457 gpui::AbsoluteLength::Pixels(pixels),
458 ))) => Some(pixels),
459 _ => None,
460 }
461 };
462 assert_eq!(size, Some(px(28.)));
463 }
464
465 #[test]
466 fn component_color_resolves_roles_against_the_active_palette() {
467 let colors = ThemeColors::light();
468 assert_eq!(
469 ComponentColor::Role(Color::Accent).resolve(&colors),
470 colors.accent.color
471 );
472 let literal = oklch(0.2, 0.0, 0.0);
473 assert_eq!(ComponentColor::from(literal).resolve(&colors), literal);
474 }
475}