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)]
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    /// Resolve defaults, then named recipes in order. Missing names add no
111    /// override, so switching to a stock theme restores stock presentation.
112    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    /// Slider perimeter radius, including both thumb layers and edge caps.
152    SliderStyle { radius: Pixels }
153}
154component_style! {
155    /// Switch track and thumb radius.
156    SwitchStyle { radius: Pixels }
157}
158component_style! {
159    /// Select trigger and detached option-panel presentation.
160    SelectStyle {
161        variant: FieldVariant,
162        height: Pixels,
163        padding_x: Pixels,
164        /// Vertical padding on the trigger, in place of v3's `py-2`.
165        ///
166        /// The trigger is `min-h` based, so a single-line value keeps the
167        /// resolved height (36px of `line-height: 20px` plus 8px each side)
168        /// while a value that wraps to two lines grows the trigger the way
169        /// upstream's `py-2` does. Unset leaves the trigger unpadded, which is
170        /// the pre-0.10.0 geometry.
171        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    /// Menu panel and row presentation; also inherited by submenus.
185    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        /// An absolute horizontal inset on each edge of a
200        /// `MenuItem::Separator`, in place of v3's proportional
201        /// `ms-[3%] w-[94%]`.
202        ///
203        /// Unset keeps that proportional rule. Set, the inset is the same
204        /// number of pixels at any panel width — AppKit's own menu separator
205        /// is inset 15pt on each side inside wider panel bounds.
206        separator_inset: Pixels,
207        /// Thickness of a `MenuItem::Separator`.
208        ///
209        /// Unset keeps `LayoutTheme::border_width`, the hairline every other
210        /// rule in the port uses.
211        separator_thickness: Pixels,
212        animate_entry: bool,
213    }
214}
215component_style! {
216    /// Shared Input/TextField/SearchField presentation. Glyph hit testing uses
217    /// the same text size; the stock 20px line advance remains unchanged.
218    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/// Button recipes can combine semantic colors with a sparse GPUI root style.
233/// Instance `sx` is refined over this style; instance builders retain precedence.
234#[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    /// The variant this recipe selects.
252    ///
253    /// Ignored on any button whose call site named a variant of its own: the
254    /// instance is the more specific source. A recipe that must survive
255    /// `.variant(..)` should say what it wants outright —
256    /// [`ButtonStyle::hover_bg`], [`ButtonStyle::background`] and their
257    /// neighbours all apply whatever variant ends up in force — rather than
258    /// express it by selecting a variant whose derived shades happen to match.
259    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    /// Capture visual metrics such as height, padding, text size and gap.
310    /// Prefer keeping flex placement and per-instance widths at the call site.
311    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/// Typed theme-owned defaults. No entry changes stock behavior until configured.
348#[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            // Mirror the component helper: only pixel heights extract.
455            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}