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    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
70/// Sparse overlay semantics for a typed component style.
71pub trait ComponentStyle: Clone + Default {
72    /// Replace only the properties supplied by `overlay`.
73    fn refine(&mut self, overlay: &Self);
74}
75
76/// Application-wide defaults plus named overlays for one component family.
77#[derive(Clone, Debug)]
78pub struct ComponentTheme<T> {
79    pub defaults: T,
80    pub recipes: HashMap<SharedString, T>,
81}
82
83impl<T: Default> Default for ComponentTheme<T> {
84    fn default() -> Self {
85        Self::new(T::default())
86    }
87}
88
89impl<T> ComponentTheme<T> {
90    pub fn new(defaults: T) -> Self {
91        Self {
92            defaults,
93            recipes: HashMap::new(),
94        }
95    }
96
97    pub fn defaults(mut self, defaults: T) -> Self {
98        self.defaults = defaults;
99        self
100    }
101
102    pub fn recipe(mut self, name: impl Into<SharedString>, style: T) -> Self {
103        self.recipes.insert(name.into(), style);
104        self
105    }
106}
107
108impl<T: ComponentStyle> ComponentTheme<T> {
109    /// Resolve defaults, then named recipes in order. Missing names add no
110    /// override, so switching to a stock theme restores stock presentation.
111    pub fn resolve(&self, names: &[SharedString]) -> T {
112        let mut style = self.defaults.clone();
113        for name in names {
114            if let Some(recipe) = self.recipes.get(name) {
115                style.refine(recipe);
116            }
117        }
118        style
119    }
120}
121
122macro_rules! component_style {
123    ($(#[$meta:meta])* $name:ident { $($(#[$field_meta:meta])* $field:ident: $ty:ty),* $(,)? }) => {
124        $(#[$meta])*
125        #[derive(Clone, Debug, Default)]
126        pub struct $name {
127            $($(#[$field_meta])* pub $field: Option<$ty>,)*
128        }
129        impl $name {
130            $(
131                $(#[$field_meta])*
132                pub fn $field(mut self, value: impl Into<$ty>) -> Self {
133                    self.$field = Some(value.into());
134                    self
135                }
136            )*
137        }
138        impl ComponentStyle for $name {
139            fn refine(&mut self, overlay: &Self) {
140                $(if overlay.$field.is_some() {
141                    self.$field = overlay.$field.clone();
142                })*
143            }
144        }
145    };
146}
147
148component_style! {
149    /// Slider perimeter radius, including both thumb layers and edge caps.
150    SliderStyle { radius: Pixels }
151}
152component_style! {
153    /// Switch track and thumb radius.
154    SwitchStyle { radius: Pixels }
155}
156component_style! {
157    /// Select trigger and detached option-panel presentation.
158    SelectStyle {
159        variant: FieldVariant,
160        height: Pixels,
161        padding_x: Pixels,
162        /// Vertical padding on the trigger, in place of v3's `py-2`.
163        ///
164        /// The trigger is `min-h` based, so a single-line value keeps the
165        /// resolved height (36px of `line-height: 20px` plus 8px each side)
166        /// while a value that wraps to two lines grows the trigger the way
167        /// upstream's `py-2` does. Unset leaves the trigger unpadded, which is
168        /// the pre-0.10.0 geometry.
169        padding_y: Pixels,
170        trigger_text_size: Pixels,
171        row_height: Pixels,
172        row_padding_x: Pixels,
173        row_padding_y: Pixels,
174        row_text_size: Pixels,
175        panel_padding: Pixels,
176        radius: Pixels,
177        row_hover_bg: ComponentColor,
178        is_bare: bool,
179    }
180}
181component_style! {
182    /// Menu panel and row presentation; also inherited by submenus.
183    MenuStyle {
184        panel_min_width: Pixels,
185        panel_max_width: Pixels,
186        panel_max_height: Pixels,
187        panel_padding: Pixels,
188        panel_gap: Pixels,
189        row_height: Pixels,
190        row_padding_x: Pixels,
191        row_padding_y: Pixels,
192        row_text_size: Pixels,
193        row_gap: Pixels,
194        row_hover_bg: ComponentColor,
195        row_hover_foreground: ComponentColor,
196        radius: Pixels,
197        /// An absolute horizontal inset on each edge of a
198        /// `MenuItem::Separator`, in place of v3's proportional
199        /// `ms-[3%] w-[94%]`.
200        ///
201        /// Unset keeps that proportional rule. Set, the inset is the same
202        /// number of pixels at any panel width — AppKit's own menu separator
203        /// is inset 15pt on each side inside wider panel bounds.
204        separator_inset: Pixels,
205        /// Thickness of a `MenuItem::Separator`.
206        ///
207        /// Unset keeps `LayoutTheme::border_width`, the hairline every other
208        /// rule in the port uses.
209        separator_thickness: Pixels,
210        animate_entry: bool,
211    }
212}
213component_style! {
214    /// Shared Input/TextField/SearchField presentation. Glyph hit testing uses
215    /// the same text size; the stock 20px line advance remains unchanged.
216    TextFieldStyle {
217        variant: FieldVariant,
218        height: Pixels,
219        padding_x: Pixels,
220        text_size: Pixels,
221        radius: Pixels,
222        is_bare: bool,
223        focus_ring: bool,
224        background: ComponentColor,
225        foreground: ComponentColor,
226        placeholder: ComponentColor,
227    }
228}
229
230/// Button recipes can combine semantic colors with a sparse GPUI root style.
231/// Instance `sx` is refined over this style; instance builders retain precedence.
232#[derive(Clone, Debug, Default)]
233pub struct ButtonStyle {
234    pub variant: Option<Variant>,
235    pub size: Option<Size>,
236    pub radius: Option<Pixels>,
237    pub background: Option<ComponentColor>,
238    pub foreground: Option<ComponentColor>,
239    pub hover_bg: Option<ComponentColor>,
240    pub hover_foreground: Option<ComponentColor>,
241    pub pressed_bg: Option<ComponentColor>,
242    pub pressed_foreground: Option<ComponentColor>,
243    pub disabled_foreground: Option<ComponentColor>,
244    pub style: Option<StyleRefinement>,
245}
246
247impl ButtonStyle {
248    /// The variant this recipe selects.
249    ///
250    /// Ignored on any button whose call site named a variant of its own: the
251    /// instance is the more specific source. A recipe that must survive
252    /// `.variant(..)` should say what it wants outright —
253    /// [`ButtonStyle::hover_bg`], [`ButtonStyle::background`] and their
254    /// neighbours all apply whatever variant ends up in force — rather than
255    /// express it by selecting a variant whose derived shades happen to match.
256    pub fn variant(mut self, variant: Variant) -> Self {
257        self.variant = Some(variant);
258        self
259    }
260
261    pub fn size(mut self, size: Size) -> Self {
262        self.size = Some(size);
263        self
264    }
265
266    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
267        self.radius = Some(radius.into());
268        self
269    }
270
271    pub fn background(mut self, color: impl Into<ComponentColor>) -> Self {
272        self.background = Some(color.into());
273        self
274    }
275
276    pub fn foreground(mut self, color: impl Into<ComponentColor>) -> Self {
277        self.foreground = Some(color.into());
278        self
279    }
280
281    pub fn hover_bg(mut self, color: impl Into<ComponentColor>) -> Self {
282        self.hover_bg = Some(color.into());
283        self
284    }
285
286    pub fn hover_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
287        self.hover_foreground = Some(color.into());
288        self
289    }
290
291    pub fn pressed_bg(mut self, color: impl Into<ComponentColor>) -> Self {
292        self.pressed_bg = Some(color.into());
293        self
294    }
295
296    pub fn pressed_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
297        self.pressed_foreground = Some(color.into());
298        self
299    }
300
301    pub fn disabled_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
302        self.disabled_foreground = Some(color.into());
303        self
304    }
305
306    /// Capture visual metrics such as height, padding, text size and gap.
307    /// Prefer keeping flex placement and per-instance widths at the call site.
308    pub fn style(mut self, style: impl FnOnce(Div) -> Div) -> Self {
309        self.style = Some(style(gpui::div()).style().clone());
310        self
311    }
312}
313
314impl ComponentStyle for ButtonStyle {
315    fn refine(&mut self, overlay: &Self) {
316        macro_rules! fields {
317            ($($field:ident),*) => {
318                $(if overlay.$field.is_some() {
319                    self.$field = overlay.$field;
320                })*
321            };
322        }
323        fields!(
324            variant,
325            size,
326            radius,
327            background,
328            foreground,
329            hover_bg,
330            hover_foreground,
331            pressed_bg,
332            pressed_foreground,
333            disabled_foreground
334        );
335        if let Some(style) = &overlay.style {
336            use gpui::Refineable as _;
337            self.style
338                .get_or_insert_with(StyleRefinement::default)
339                .refine(style);
340        }
341    }
342}
343
344/// Typed theme-owned defaults. No entry changes stock behavior until configured.
345#[derive(Clone, Debug, Default)]
346pub struct ComponentThemes {
347    pub slider: ComponentTheme<SliderStyle>,
348    pub switch: ComponentTheme<SwitchStyle>,
349    pub select: ComponentTheme<SelectStyle>,
350    pub menu: ComponentTheme<MenuStyle>,
351    pub button: ComponentTheme<ButtonStyle>,
352    pub text_field: ComponentTheme<TextFieldStyle>,
353}
354
355impl ComponentThemes {
356    pub fn slider(mut self, slider: ComponentTheme<SliderStyle>) -> Self {
357        self.slider = slider;
358        self
359    }
360
361    pub fn switch(mut self, switch: ComponentTheme<SwitchStyle>) -> Self {
362        self.switch = switch;
363        self
364    }
365
366    pub fn select(mut self, select: ComponentTheme<SelectStyle>) -> Self {
367        self.select = select;
368        self
369    }
370
371    pub fn menu(mut self, menu: ComponentTheme<MenuStyle>) -> Self {
372        self.menu = menu;
373        self
374    }
375
376    pub fn button(mut self, button: ComponentTheme<ButtonStyle>) -> Self {
377        self.button = button;
378        self
379    }
380
381    pub fn text_field(mut self, text_field: ComponentTheme<TextFieldStyle>) -> Self {
382        self.text_field = text_field;
383        self
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::ThemeColors;
391    use gpui::px;
392    use herogpui_core::oklch;
393
394    #[test]
395    fn empty_styles_resolve_to_stock_none_fields() {
396        let themes = ComponentThemes::default();
397        let slider = themes.slider.resolve(&[]);
398        assert_eq!(slider.radius, None);
399        let button = themes.button.resolve(&[]);
400        assert_eq!(button.variant, None);
401        assert!(button.style.is_none());
402    }
403
404    #[test]
405    fn recipes_refine_in_order_and_missing_names_are_ignored() {
406        let theme = ComponentTheme::new(MenuStyle::default().panel_gap(px(2.)))
407            .recipe(
408                "compact",
409                MenuStyle::default().row_height(px(28.)).panel_gap(px(0.)),
410            )
411            .recipe(
412                "accent",
413                MenuStyle::default().row_hover_bg(ComponentColor::Role(Color::Accent)),
414            );
415        let missing = theme.resolve(&["missing".into()]);
416        assert_eq!(missing.panel_gap, Some(px(2.)));
417        assert_eq!(missing.row_height, None);
418
419        let stacked = theme.resolve(&["compact".into(), "accent".into()]);
420        assert_eq!(stacked.panel_gap, Some(px(0.)));
421        assert_eq!(stacked.row_height, Some(px(28.)));
422        assert_eq!(
423            stacked.row_hover_bg,
424            Some(ComponentColor::Role(Color::Accent))
425        );
426    }
427
428    #[test]
429    fn text_field_theme_can_configure_focus_ring_visibility() {
430        let theme = ComponentTheme::new(TextFieldStyle::default().focus_ring(false))
431            .recipe("ring", TextFieldStyle::default().focus_ring(true));
432        assert_eq!(theme.resolve(&[]).focus_ring, Some(false));
433        assert_eq!(theme.resolve(&["ring".into()]).focus_ring, Some(true));
434    }
435
436    #[test]
437    fn button_style_merges_refinements() {
438        let base = ButtonStyle::default()
439            .radius(px(8.))
440            .style(|el| el.h(px(36.)).px(px(16.)));
441        let overlay = ButtonStyle::default()
442            .hover_bg(ComponentColor::Muted)
443            .style(|el| el.h(px(28.)));
444        let mut merged = base;
445        merged.refine(&overlay);
446        assert_eq!(merged.radius, Some(px(8.)));
447        assert_eq!(merged.hover_bg, Some(ComponentColor::Muted));
448        let boxed = Some(Box::new(merged.style.unwrap()));
449        let size = {
450            // Mirror the component helper: only pixel heights extract.
451            match boxed.as_ref().unwrap().size.height {
452                Some(gpui::Length::Definite(gpui::DefiniteLength::Absolute(
453                    gpui::AbsoluteLength::Pixels(pixels),
454                ))) => Some(pixels),
455                _ => None,
456            }
457        };
458        assert_eq!(size, Some(px(28.)));
459    }
460
461    #[test]
462    fn component_color_resolves_roles_against_the_active_palette() {
463        let colors = ThemeColors::light();
464        assert_eq!(
465            ComponentColor::Role(Color::Accent).resolve(&colors),
466            colors.accent.color
467        );
468        let literal = oklch(0.2, 0.0, 0.0);
469        assert_eq!(ComponentColor::from(literal).resolve(&colors), literal);
470    }
471}