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),
18 Background,
20 Foreground,
22 Muted,
24 Surface,
26 SurfaceForeground,
28 SurfaceSecondary,
30 SurfaceTertiary,
32 Border,
34 FieldBackground,
36 FieldForeground,
38 FieldPlaceholder,
40 Role(Color),
42 RoleForeground(Color),
44 RoleHover(Color),
46 RoleSoft(Color),
48}
49
50impl From<Hsla> for ComponentColor {
51 fn from(color: Hsla) -> Self {
52 Self::Literal(color)
53 }
54}
55
56impl ComponentColor {
57 pub fn resolve(self, colors: &ThemeColors) -> Hsla {
59 let role = |role| match role {
60 Color::Default => &colors.default,
61 Color::Accent => &colors.accent,
62 Color::Success => &colors.success,
63 Color::Warning => &colors.warning,
64 Color::Danger => &colors.danger,
65 };
66 match self {
67 Self::Literal(color) => color,
68 Self::Background => colors.background,
69 Self::Foreground => colors.foreground,
70 Self::Muted => colors.muted,
71 Self::Surface => colors.surface.background,
72 Self::SurfaceForeground => colors.surface.foreground,
73 Self::SurfaceSecondary => colors.surface_secondary,
74 Self::SurfaceTertiary => colors.surface_tertiary,
75 Self::Border => colors.border,
76 Self::FieldBackground => colors.field.background,
77 Self::FieldForeground => colors.field.foreground,
78 Self::FieldPlaceholder => colors.field.placeholder,
79 Self::Role(color) => role(color).color,
80 Self::RoleForeground(color) => role(color).foreground,
81 Self::RoleHover(color) => role(color).hover(),
82 Self::RoleSoft(color) => role(color).soft(),
83 }
84 }
85}
86
87pub trait ComponentStyle: Clone + Default {
89 fn refine(&mut self, overlay: &Self);
91}
92
93#[must_use = "builder methods return a new value; the original is unchanged"]
95#[derive(Clone, Debug)]
96#[non_exhaustive]
97pub struct ComponentTheme<T> {
98 pub defaults: T,
100 pub recipes: HashMap<SharedString, T>,
102}
103
104impl<T: Default> Default for ComponentTheme<T> {
105 fn default() -> Self {
106 Self::new(T::default())
107 }
108}
109
110impl<T> ComponentTheme<T> {
111 pub fn new(defaults: T) -> Self {
113 Self {
114 defaults,
115 recipes: HashMap::new(),
116 }
117 }
118
119 pub fn defaults(mut self, defaults: T) -> Self {
121 self.defaults = defaults;
122 self
123 }
124
125 pub fn recipe(mut self, name: impl Into<SharedString>, style: T) -> Self {
127 self.recipes.insert(name.into(), style);
128 self
129 }
130}
131
132impl<T: ComponentStyle> ComponentTheme<T> {
133 pub fn resolve(&self, names: &[SharedString]) -> T {
136 let mut style = self.defaults.clone();
137 for name in names {
138 if let Some(recipe) = self.recipes.get(name) {
139 style.refine(recipe);
140 }
141 }
142 style
143 }
144}
145
146macro_rules! component_style {
147 ($(#[$meta:meta])* $name:ident { $($(#[$field_meta:meta])* $field:ident: $ty:ty),* $(,)? }) => {
148 $(#[$meta])*
149 #[must_use = "builder methods return a new value; the original is unchanged"]
150 #[derive(Clone, Debug, Default)]
151 #[non_exhaustive]
152 pub struct $name {
153 $($(#[$field_meta])* pub $field: Option<$ty>,)*
154 }
155 impl $name {
156 $(
157 $(#[$field_meta])*
158 pub fn $field(mut self, value: impl Into<$ty>) -> Self {
159 self.$field = Some(value.into());
160 self
161 }
162 )*
163 }
164 impl ComponentStyle for $name {
165 fn refine(&mut self, overlay: &Self) {
166 $(if overlay.$field.is_some() {
167 self.$field = overlay.$field.clone();
168 })*
169 }
170 }
171 };
172}
173
174component_style! {
175 SliderStyle {
177 radius: Pixels,
179 }
180}
181component_style! {
182 SwitchStyle {
184 radius: Pixels,
186 }
187}
188component_style! {
189 SelectStyle {
191 variant: FieldVariant,
193 height: Pixels,
195 padding_x: Pixels,
197 padding_y: Pixels,
205 trigger_text_size: Pixels,
207 row_height: Pixels,
209 row_padding_x: Pixels,
211 row_padding_y: Pixels,
213 row_text_size: Pixels,
215 panel_padding: Pixels,
217 radius: Pixels,
219 row_hover_bg: ComponentColor,
221 is_bare: bool,
223 }
224}
225component_style! {
226 MenuStyle {
228 panel_min_width: Pixels,
230 panel_max_width: Pixels,
232 panel_max_height: Pixels,
234 panel_padding: Pixels,
236 panel_gap: Pixels,
238 row_height: Pixels,
240 row_padding_x: Pixels,
242 row_padding_y: Pixels,
244 row_text_size: Pixels,
246 row_gap: Pixels,
248 row_hover_bg: ComponentColor,
250 row_hover_foreground: ComponentColor,
252 radius: Pixels,
254 separator_inset: Pixels,
262 separator_thickness: Pixels,
267 animate_entry: bool,
269 }
270}
271component_style! {
272 TextFieldStyle {
275 variant: FieldVariant,
277 height: Pixels,
279 padding_x: Pixels,
281 text_size: Pixels,
283 radius: Pixels,
285 is_bare: bool,
287 focus_ring: bool,
289 background: ComponentColor,
291 foreground: ComponentColor,
293 placeholder: ComponentColor,
295 }
296}
297
298#[must_use = "builder methods return a new value; the original is unchanged"]
301#[derive(Clone, Debug, Default)]
302#[non_exhaustive]
303pub struct ButtonStyle {
304 pub variant: Option<Variant>,
306 pub size: Option<Size>,
308 pub radius: Option<Pixels>,
310 pub background: Option<ComponentColor>,
312 pub foreground: Option<ComponentColor>,
314 pub hover_bg: Option<ComponentColor>,
316 pub hover_foreground: Option<ComponentColor>,
318 pub pressed_bg: Option<ComponentColor>,
320 pub pressed_foreground: Option<ComponentColor>,
322 pub disabled_foreground: Option<ComponentColor>,
324 pub style: Option<StyleRefinement>,
326}
327
328impl ButtonStyle {
329 pub fn variant(mut self, variant: Variant) -> Self {
338 self.variant = Some(variant);
339 self
340 }
341
342 pub fn size(mut self, size: Size) -> Self {
344 self.size = Some(size);
345 self
346 }
347
348 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
350 self.radius = Some(radius.into());
351 self
352 }
353
354 pub fn background(mut self, color: impl Into<ComponentColor>) -> Self {
356 self.background = Some(color.into());
357 self
358 }
359
360 pub fn foreground(mut self, color: impl Into<ComponentColor>) -> Self {
362 self.foreground = Some(color.into());
363 self
364 }
365
366 pub fn hover_bg(mut self, color: impl Into<ComponentColor>) -> Self {
368 self.hover_bg = Some(color.into());
369 self
370 }
371
372 pub fn hover_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
374 self.hover_foreground = Some(color.into());
375 self
376 }
377
378 pub fn pressed_bg(mut self, color: impl Into<ComponentColor>) -> Self {
380 self.pressed_bg = Some(color.into());
381 self
382 }
383
384 pub fn pressed_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
386 self.pressed_foreground = Some(color.into());
387 self
388 }
389
390 pub fn disabled_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
392 self.disabled_foreground = Some(color.into());
393 self
394 }
395
396 pub fn style(mut self, style: impl FnOnce(Div) -> Div) -> Self {
399 self.style = Some(style(gpui::div()).style().clone());
400 self
401 }
402}
403
404impl ComponentStyle for ButtonStyle {
405 fn refine(&mut self, overlay: &Self) {
406 macro_rules! fields {
407 ($($field:ident),*) => {
408 $(if overlay.$field.is_some() {
409 self.$field = overlay.$field;
410 })*
411 };
412 }
413 fields!(
414 variant,
415 size,
416 radius,
417 background,
418 foreground,
419 hover_bg,
420 hover_foreground,
421 pressed_bg,
422 pressed_foreground,
423 disabled_foreground
424 );
425 if let Some(style) = &overlay.style {
426 use gpui::Refineable as _;
427 self.style
428 .get_or_insert_with(StyleRefinement::default)
429 .refine(style);
430 }
431 }
432}
433
434#[must_use = "builder methods return a new value; the original is unchanged"]
436#[derive(Clone, Debug, Default)]
437#[non_exhaustive]
438pub struct ComponentThemes {
439 pub slider: ComponentTheme<SliderStyle>,
441 pub switch: ComponentTheme<SwitchStyle>,
443 pub select: ComponentTheme<SelectStyle>,
445 pub menu: ComponentTheme<MenuStyle>,
447 pub button: ComponentTheme<ButtonStyle>,
449 pub text_field: ComponentTheme<TextFieldStyle>,
451}
452
453impl ComponentThemes {
454 pub fn slider(mut self, slider: ComponentTheme<SliderStyle>) -> Self {
456 self.slider = slider;
457 self
458 }
459
460 pub fn switch(mut self, switch: ComponentTheme<SwitchStyle>) -> Self {
462 self.switch = switch;
463 self
464 }
465
466 pub fn select(mut self, select: ComponentTheme<SelectStyle>) -> Self {
468 self.select = select;
469 self
470 }
471
472 pub fn menu(mut self, menu: ComponentTheme<MenuStyle>) -> Self {
474 self.menu = menu;
475 self
476 }
477
478 pub fn button(mut self, button: ComponentTheme<ButtonStyle>) -> Self {
480 self.button = button;
481 self
482 }
483
484 pub fn text_field(mut self, text_field: ComponentTheme<TextFieldStyle>) -> Self {
486 self.text_field = text_field;
487 self
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::ThemeColors;
495 use gpui::px;
496 use herogpui_core::oklch;
497
498 #[test]
499 fn empty_styles_resolve_to_stock_none_fields() {
500 let themes = ComponentThemes::default();
501 let slider = themes.slider.resolve(&[]);
502 assert_eq!(slider.radius, None);
503 let button = themes.button.resolve(&[]);
504 assert_eq!(button.variant, None);
505 assert!(button.style.is_none());
506 }
507
508 #[test]
509 fn recipes_refine_in_order_and_missing_names_are_ignored() {
510 let theme = ComponentTheme::new(MenuStyle::default().panel_gap(px(2.)))
511 .recipe(
512 "compact",
513 MenuStyle::default().row_height(px(28.)).panel_gap(px(0.)),
514 )
515 .recipe(
516 "accent",
517 MenuStyle::default().row_hover_bg(ComponentColor::Role(Color::Accent)),
518 );
519 let missing = theme.resolve(&["missing".into()]);
520 assert_eq!(missing.panel_gap, Some(px(2.)));
521 assert_eq!(missing.row_height, None);
522
523 let stacked = theme.resolve(&["compact".into(), "accent".into()]);
524 assert_eq!(stacked.panel_gap, Some(px(0.)));
525 assert_eq!(stacked.row_height, Some(px(28.)));
526 assert_eq!(
527 stacked.row_hover_bg,
528 Some(ComponentColor::Role(Color::Accent))
529 );
530 }
531
532 #[test]
533 fn text_field_theme_can_configure_focus_ring_visibility() {
534 let theme = ComponentTheme::new(TextFieldStyle::default().focus_ring(false))
535 .recipe("ring", TextFieldStyle::default().focus_ring(true));
536 assert_eq!(theme.resolve(&[]).focus_ring, Some(false));
537 assert_eq!(theme.resolve(&["ring".into()]).focus_ring, Some(true));
538 }
539
540 #[test]
541 fn button_style_merges_refinements() {
542 let base = ButtonStyle::default()
543 .radius(px(8.))
544 .style(|el| el.h(px(36.)).px(px(16.)));
545 let overlay = ButtonStyle::default()
546 .hover_bg(ComponentColor::Muted)
547 .style(|el| el.h(px(28.)));
548 let mut merged = base;
549 merged.refine(&overlay);
550 assert_eq!(merged.radius, Some(px(8.)));
551 assert_eq!(merged.hover_bg, Some(ComponentColor::Muted));
552 let boxed = Some(Box::new(merged.style.unwrap()));
553 let size = {
554 match boxed.as_ref().unwrap().size.height {
556 Some(gpui::Length::Definite(gpui::DefiniteLength::Absolute(
557 gpui::AbsoluteLength::Pixels(pixels),
558 ))) => Some(pixels),
559 _ => None,
560 }
561 };
562 assert_eq!(size, Some(px(28.)));
563 }
564
565 #[test]
566 fn component_color_resolves_roles_against_the_active_palette() {
567 let colors = ThemeColors::light();
568 assert_eq!(
569 ComponentColor::Role(Color::Accent).resolve(&colors),
570 colors.accent.color
571 );
572 let literal = oklch(0.2, 0.0, 0.0);
573 assert_eq!(ComponentColor::from(literal).resolve(&colors), literal);
574 }
575}