Skip to main content

herogpui_theme/
components.rs

1//! Sparse, typed component defaults and reusable named recipes.
2//!
3//! Empty styles preserve each component's stock behavior. Recipes are resolved
4//! against the active theme during render, not captured when a builder is made.
5
6use 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/// A color resolved from the active palette, or an application-defined literal.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum ComponentColor {
16    /// A literal color supplied by the application.
17    Literal(Hsla),
18    /// The theme's `--background`.
19    Background,
20    /// The theme's `--foreground`.
21    Foreground,
22    /// The theme's `--muted`.
23    Muted,
24    /// The `--surface` background.
25    Surface,
26    /// The `--surface-foreground` color.
27    SurfaceForeground,
28    /// The `--surface-secondary` color.
29    SurfaceSecondary,
30    /// The `--surface-tertiary` color.
31    SurfaceTertiary,
32    /// The theme's `--border`.
33    Border,
34    /// The `--field-background` color.
35    FieldBackground,
36    /// The `--field-foreground` color.
37    FieldForeground,
38    /// The `--field-placeholder` color.
39    FieldPlaceholder,
40    /// A role's base color.
41    Role(Color),
42    /// A role's on-color foreground.
43    RoleForeground(Color),
44    /// A role's hover shade.
45    RoleHover(Color),
46    /// A role's soft (tinted) shade.
47    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    /// Resolves this color against `colors`.
58    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
87/// Sparse overlay semantics for a typed component style.
88pub trait ComponentStyle: Clone + Default {
89    /// Replace only the properties supplied by `overlay`.
90    fn refine(&mut self, overlay: &Self);
91}
92
93/// Application-wide defaults plus named overlays for one component family.
94#[must_use = "builder methods return a new value; the original is unchanged"]
95#[derive(Clone, Debug)]
96#[non_exhaustive]
97pub struct ComponentTheme<T> {
98    /// Defaults applied to every instance of the component.
99    pub defaults: T,
100    /// Named overlays, refined over the defaults in the order they are requested.
101    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    /// Creates a theme with the given defaults and no recipes.
112    pub fn new(defaults: T) -> Self {
113        Self {
114            defaults,
115            recipes: HashMap::new(),
116        }
117    }
118
119    /// Replaces the defaults.
120    pub fn defaults(mut self, defaults: T) -> Self {
121        self.defaults = defaults;
122        self
123    }
124
125    /// Registers a named recipe, replacing any earlier recipe of that name.
126    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    /// Resolve defaults, then named recipes in order. Missing names add no
134    /// override, so switching to a stock theme restores stock presentation.
135    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    /// Slider perimeter radius, including both thumb layers and edge caps.
176    SliderStyle {
177        /// Corner radius.
178        radius: Pixels,
179    }
180}
181component_style! {
182    /// Switch track and thumb radius.
183    SwitchStyle {
184        /// Corner radius.
185        radius: Pixels,
186    }
187}
188component_style! {
189    /// Select trigger and detached option-panel presentation.
190    SelectStyle {
191        /// Field variant.
192        variant: FieldVariant,
193        /// Trigger height.
194        height: Pixels,
195        /// Horizontal padding on the trigger.
196        padding_x: Pixels,
197        /// Vertical padding on the trigger, in place of v3's `py-2`.
198        ///
199        /// The trigger is `min-h` based, so a single-line value keeps the
200        /// resolved height (36px of `line-height: 20px` plus 8px each side)
201        /// while a value that wraps to two lines grows the trigger the way
202        /// upstream's `py-2` does. Unset leaves the trigger unpadded, which is
203        /// the pre-0.10.0 geometry.
204        padding_y: Pixels,
205        /// Text size in the trigger.
206        trigger_text_size: Pixels,
207        /// Option row height.
208        row_height: Pixels,
209        /// Horizontal padding on an option row.
210        row_padding_x: Pixels,
211        /// Vertical padding on an option row.
212        row_padding_y: Pixels,
213        /// Text size in an option row.
214        row_text_size: Pixels,
215        /// Padding inside the option panel.
216        panel_padding: Pixels,
217        /// Corner radius.
218        radius: Pixels,
219        /// Background of a hovered option row.
220        row_hover_bg: ComponentColor,
221        /// Whether the trigger drops its own field chrome.
222        is_bare: bool,
223    }
224}
225component_style! {
226    /// Menu panel and row presentation; also inherited by submenus.
227    MenuStyle {
228        /// Minimum panel width.
229        panel_min_width: Pixels,
230        /// Maximum panel width.
231        panel_max_width: Pixels,
232        /// Maximum panel height.
233        panel_max_height: Pixels,
234        /// Padding inside the panel.
235        panel_padding: Pixels,
236        /// Gap between rows in the panel.
237        panel_gap: Pixels,
238        /// Row height.
239        row_height: Pixels,
240        /// Horizontal padding on a row.
241        row_padding_x: Pixels,
242        /// Vertical padding on a row.
243        row_padding_y: Pixels,
244        /// Row text size.
245        row_text_size: Pixels,
246        /// Gap between a row's contents.
247        row_gap: Pixels,
248        /// Background of a hovered row.
249        row_hover_bg: ComponentColor,
250        /// Foreground of a hovered row.
251        row_hover_foreground: ComponentColor,
252        /// Corner radius.
253        radius: Pixels,
254        /// An absolute horizontal inset on each edge of a
255        /// `MenuItem::Separator`, in place of v3's proportional
256        /// `ms-[3%] w-[94%]`.
257        ///
258        /// Unset keeps that proportional rule. Set, the inset is the same
259        /// number of pixels at any panel width — AppKit's own menu separator
260        /// is inset 15pt on each side inside wider panel bounds.
261        separator_inset: Pixels,
262        /// Thickness of a `MenuItem::Separator`.
263        ///
264        /// Unset keeps `LayoutTheme::border_width`, the hairline every other
265        /// rule in the port uses.
266        separator_thickness: Pixels,
267        /// Whether the panel and its submenus play their entry animation.
268        animate_entry: bool,
269    }
270}
271component_style! {
272    /// Shared Input/TextField/SearchField presentation. Glyph hit testing uses
273    /// the same text size; the stock 20px line advance remains unchanged.
274    TextFieldStyle {
275        /// Field variant.
276        variant: FieldVariant,
277        /// Field height.
278        height: Pixels,
279        /// Horizontal padding.
280        padding_x: Pixels,
281        /// Text size.
282        text_size: Pixels,
283        /// Corner radius.
284        radius: Pixels,
285        /// Whether the field drops its own chrome (background, border, shadow and rings).
286        is_bare: bool,
287        /// Whether the focus ring is drawn.
288        focus_ring: bool,
289        /// Background color.
290        background: ComponentColor,
291        /// Foreground (text) color.
292        foreground: ComponentColor,
293        /// Placeholder color.
294        placeholder: ComponentColor,
295    }
296}
297
298/// Button recipes can combine semantic colors with a sparse GPUI root style.
299/// Instance `sx` is refined over this style; instance builders retain precedence.
300#[must_use = "builder methods return a new value; the original is unchanged"]
301#[derive(Clone, Debug, Default)]
302#[non_exhaustive]
303pub struct ButtonStyle {
304    /// The variant this recipe selects.
305    pub variant: Option<Variant>,
306    /// The size this recipe selects.
307    pub size: Option<Size>,
308    /// Corner radius.
309    pub radius: Option<Pixels>,
310    /// Resting background.
311    pub background: Option<ComponentColor>,
312    /// Resting foreground.
313    pub foreground: Option<ComponentColor>,
314    /// Background while hovered.
315    pub hover_bg: Option<ComponentColor>,
316    /// Foreground while hovered.
317    pub hover_foreground: Option<ComponentColor>,
318    /// Background while pressed.
319    pub pressed_bg: Option<ComponentColor>,
320    /// Foreground while pressed.
321    pub pressed_foreground: Option<ComponentColor>,
322    /// Foreground while disabled.
323    pub disabled_foreground: Option<ComponentColor>,
324    /// A sparse GPUI root style refined over the button.
325    pub style: Option<StyleRefinement>,
326}
327
328impl ButtonStyle {
329    /// The variant this recipe selects.
330    ///
331    /// Ignored on any button whose call site named a variant of its own: the
332    /// instance is the more specific source. A recipe that must survive
333    /// `.variant(..)` should say what it wants outright —
334    /// [`ButtonStyle::hover_bg`], [`ButtonStyle::background`] and their
335    /// neighbours all apply whatever variant ends up in force — rather than
336    /// express it by selecting a variant whose derived shades happen to match.
337    pub fn variant(mut self, variant: Variant) -> Self {
338        self.variant = Some(variant);
339        self
340    }
341
342    /// Sets the size.
343    pub fn size(mut self, size: Size) -> Self {
344        self.size = Some(size);
345        self
346    }
347
348    /// Sets the corner radius.
349    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
350        self.radius = Some(radius.into());
351        self
352    }
353
354    /// Sets the resting background.
355    pub fn background(mut self, color: impl Into<ComponentColor>) -> Self {
356        self.background = Some(color.into());
357        self
358    }
359
360    /// Sets the resting foreground.
361    pub fn foreground(mut self, color: impl Into<ComponentColor>) -> Self {
362        self.foreground = Some(color.into());
363        self
364    }
365
366    /// Sets the hover background.
367    pub fn hover_bg(mut self, color: impl Into<ComponentColor>) -> Self {
368        self.hover_bg = Some(color.into());
369        self
370    }
371
372    /// Sets the hover foreground.
373    pub fn hover_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
374        self.hover_foreground = Some(color.into());
375        self
376    }
377
378    /// Sets the pressed background.
379    pub fn pressed_bg(mut self, color: impl Into<ComponentColor>) -> Self {
380        self.pressed_bg = Some(color.into());
381        self
382    }
383
384    /// Sets the pressed foreground.
385    pub fn pressed_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
386        self.pressed_foreground = Some(color.into());
387        self
388    }
389
390    /// Sets the disabled foreground.
391    pub fn disabled_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
392        self.disabled_foreground = Some(color.into());
393        self
394    }
395
396    /// Capture visual metrics such as height, padding, text size and gap.
397    /// Prefer keeping flex placement and per-instance widths at the call site.
398    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/// Typed theme-owned defaults. No entry changes stock behavior until configured.
435#[must_use = "builder methods return a new value; the original is unchanged"]
436#[derive(Clone, Debug, Default)]
437#[non_exhaustive]
438pub struct ComponentThemes {
439    /// Slider defaults and recipes.
440    pub slider: ComponentTheme<SliderStyle>,
441    /// Switch defaults and recipes.
442    pub switch: ComponentTheme<SwitchStyle>,
443    /// Select defaults and recipes.
444    pub select: ComponentTheme<SelectStyle>,
445    /// Menu defaults and recipes.
446    pub menu: ComponentTheme<MenuStyle>,
447    /// Button defaults and recipes.
448    pub button: ComponentTheme<ButtonStyle>,
449    /// Text field defaults and recipes.
450    pub text_field: ComponentTheme<TextFieldStyle>,
451}
452
453impl ComponentThemes {
454    /// Sets the slider defaults and recipes.
455    pub fn slider(mut self, slider: ComponentTheme<SliderStyle>) -> Self {
456        self.slider = slider;
457        self
458    }
459
460    /// Sets the switch defaults and recipes.
461    pub fn switch(mut self, switch: ComponentTheme<SwitchStyle>) -> Self {
462        self.switch = switch;
463        self
464    }
465
466    /// Sets the select defaults and recipes.
467    pub fn select(mut self, select: ComponentTheme<SelectStyle>) -> Self {
468        self.select = select;
469        self
470    }
471
472    /// Sets the menu defaults and recipes.
473    pub fn menu(mut self, menu: ComponentTheme<MenuStyle>) -> Self {
474        self.menu = menu;
475        self
476    }
477
478    /// Sets the button defaults and recipes.
479    pub fn button(mut self, button: ComponentTheme<ButtonStyle>) -> Self {
480        self.button = button;
481        self
482    }
483
484    /// Sets the text field defaults and recipes.
485    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            // Mirror the component helper: only pixel heights extract.
555            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}