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        trigger_text_size: Pixels,
163        row_height: Pixels,
164        row_padding_x: Pixels,
165        row_padding_y: Pixels,
166        row_text_size: Pixels,
167        panel_padding: Pixels,
168        radius: Pixels,
169        row_hover_bg: ComponentColor,
170        is_bare: bool,
171    }
172}
173component_style! {
174    /// Menu panel and row presentation; also inherited by submenus.
175    MenuStyle {
176        panel_min_width: Pixels,
177        panel_max_width: Pixels,
178        panel_max_height: Pixels,
179        panel_padding: Pixels,
180        panel_gap: Pixels,
181        row_height: Pixels,
182        row_padding_x: Pixels,
183        row_padding_y: Pixels,
184        row_text_size: Pixels,
185        row_gap: Pixels,
186        row_hover_bg: ComponentColor,
187        row_hover_foreground: ComponentColor,
188        radius: Pixels,
189        animate_entry: bool,
190    }
191}
192component_style! {
193    /// Shared Input/TextField/SearchField presentation. Glyph hit testing uses
194    /// the same text size; the stock 20px line advance remains unchanged.
195    TextFieldStyle {
196        variant: FieldVariant,
197        height: Pixels,
198        padding_x: Pixels,
199        text_size: Pixels,
200        radius: Pixels,
201        is_bare: bool,
202        focus_ring: bool,
203        background: ComponentColor,
204        foreground: ComponentColor,
205        placeholder: ComponentColor,
206    }
207}
208
209/// Button recipes can combine semantic colors with a sparse GPUI root style.
210/// Instance `sx` is refined over this style; instance builders retain precedence.
211#[derive(Clone, Debug, Default)]
212pub struct ButtonStyle {
213    pub variant: Option<Variant>,
214    pub size: Option<Size>,
215    pub radius: Option<Pixels>,
216    pub background: Option<ComponentColor>,
217    pub foreground: Option<ComponentColor>,
218    pub hover_bg: Option<ComponentColor>,
219    pub hover_foreground: Option<ComponentColor>,
220    pub pressed_bg: Option<ComponentColor>,
221    pub pressed_foreground: Option<ComponentColor>,
222    pub disabled_foreground: Option<ComponentColor>,
223    pub style: Option<StyleRefinement>,
224}
225
226impl ButtonStyle {
227    pub fn variant(mut self, variant: Variant) -> Self {
228        self.variant = Some(variant);
229        self
230    }
231
232    pub fn size(mut self, size: Size) -> Self {
233        self.size = Some(size);
234        self
235    }
236
237    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
238        self.radius = Some(radius.into());
239        self
240    }
241
242    pub fn background(mut self, color: impl Into<ComponentColor>) -> Self {
243        self.background = Some(color.into());
244        self
245    }
246
247    pub fn foreground(mut self, color: impl Into<ComponentColor>) -> Self {
248        self.foreground = Some(color.into());
249        self
250    }
251
252    pub fn hover_bg(mut self, color: impl Into<ComponentColor>) -> Self {
253        self.hover_bg = Some(color.into());
254        self
255    }
256
257    pub fn hover_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
258        self.hover_foreground = Some(color.into());
259        self
260    }
261
262    pub fn pressed_bg(mut self, color: impl Into<ComponentColor>) -> Self {
263        self.pressed_bg = Some(color.into());
264        self
265    }
266
267    pub fn pressed_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
268        self.pressed_foreground = Some(color.into());
269        self
270    }
271
272    pub fn disabled_foreground(mut self, color: impl Into<ComponentColor>) -> Self {
273        self.disabled_foreground = Some(color.into());
274        self
275    }
276
277    /// Capture visual metrics such as height, padding, text size and gap.
278    /// Prefer keeping flex placement and per-instance widths at the call site.
279    pub fn style(mut self, style: impl FnOnce(Div) -> Div) -> Self {
280        self.style = Some(style(gpui::div()).style().clone());
281        self
282    }
283}
284
285impl ComponentStyle for ButtonStyle {
286    fn refine(&mut self, overlay: &Self) {
287        macro_rules! fields {
288            ($($field:ident),*) => {
289                $(if overlay.$field.is_some() {
290                    self.$field = overlay.$field;
291                })*
292            };
293        }
294        fields!(
295            variant,
296            size,
297            radius,
298            background,
299            foreground,
300            hover_bg,
301            hover_foreground,
302            pressed_bg,
303            pressed_foreground,
304            disabled_foreground
305        );
306        if let Some(style) = &overlay.style {
307            use gpui::Refineable as _;
308            self.style
309                .get_or_insert_with(StyleRefinement::default)
310                .refine(style);
311        }
312    }
313}
314
315/// Typed theme-owned defaults. No entry changes stock behavior until configured.
316#[derive(Clone, Debug, Default)]
317pub struct ComponentThemes {
318    pub slider: ComponentTheme<SliderStyle>,
319    pub switch: ComponentTheme<SwitchStyle>,
320    pub select: ComponentTheme<SelectStyle>,
321    pub menu: ComponentTheme<MenuStyle>,
322    pub button: ComponentTheme<ButtonStyle>,
323    pub text_field: ComponentTheme<TextFieldStyle>,
324}
325
326impl ComponentThemes {
327    pub fn slider(mut self, slider: ComponentTheme<SliderStyle>) -> Self {
328        self.slider = slider;
329        self
330    }
331
332    pub fn switch(mut self, switch: ComponentTheme<SwitchStyle>) -> Self {
333        self.switch = switch;
334        self
335    }
336
337    pub fn select(mut self, select: ComponentTheme<SelectStyle>) -> Self {
338        self.select = select;
339        self
340    }
341
342    pub fn menu(mut self, menu: ComponentTheme<MenuStyle>) -> Self {
343        self.menu = menu;
344        self
345    }
346
347    pub fn button(mut self, button: ComponentTheme<ButtonStyle>) -> Self {
348        self.button = button;
349        self
350    }
351
352    pub fn text_field(mut self, text_field: ComponentTheme<TextFieldStyle>) -> Self {
353        self.text_field = text_field;
354        self
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::ThemeColors;
362    use gpui::px;
363    use herogpui_core::oklch;
364
365    #[test]
366    fn empty_styles_resolve_to_stock_none_fields() {
367        let themes = ComponentThemes::default();
368        let slider = themes.slider.resolve(&[]);
369        assert_eq!(slider.radius, None);
370        let button = themes.button.resolve(&[]);
371        assert_eq!(button.variant, None);
372        assert!(button.style.is_none());
373    }
374
375    #[test]
376    fn recipes_refine_in_order_and_missing_names_are_ignored() {
377        let theme = ComponentTheme::new(MenuStyle::default().panel_gap(px(2.)))
378            .recipe(
379                "compact",
380                MenuStyle::default().row_height(px(28.)).panel_gap(px(0.)),
381            )
382            .recipe(
383                "accent",
384                MenuStyle::default().row_hover_bg(ComponentColor::Role(Color::Accent)),
385            );
386        let missing = theme.resolve(&["missing".into()]);
387        assert_eq!(missing.panel_gap, Some(px(2.)));
388        assert_eq!(missing.row_height, None);
389
390        let stacked = theme.resolve(&["compact".into(), "accent".into()]);
391        assert_eq!(stacked.panel_gap, Some(px(0.)));
392        assert_eq!(stacked.row_height, Some(px(28.)));
393        assert_eq!(
394            stacked.row_hover_bg,
395            Some(ComponentColor::Role(Color::Accent))
396        );
397    }
398
399    #[test]
400    fn text_field_theme_can_configure_focus_ring_visibility() {
401        let theme = ComponentTheme::new(TextFieldStyle::default().focus_ring(false))
402            .recipe("ring", TextFieldStyle::default().focus_ring(true));
403        assert_eq!(theme.resolve(&[]).focus_ring, Some(false));
404        assert_eq!(theme.resolve(&["ring".into()]).focus_ring, Some(true));
405    }
406
407    #[test]
408    fn button_style_merges_refinements() {
409        let base = ButtonStyle::default()
410            .radius(px(8.))
411            .style(|el| el.h(px(36.)).px(px(16.)));
412        let overlay = ButtonStyle::default()
413            .hover_bg(ComponentColor::Muted)
414            .style(|el| el.h(px(28.)));
415        let mut merged = base;
416        merged.refine(&overlay);
417        assert_eq!(merged.radius, Some(px(8.)));
418        assert_eq!(merged.hover_bg, Some(ComponentColor::Muted));
419        let boxed = Some(Box::new(merged.style.unwrap()));
420        let size = {
421            // Mirror the component helper: only pixel heights extract.
422            match boxed.as_ref().unwrap().size.height {
423                Some(gpui::Length::Definite(gpui::DefiniteLength::Absolute(
424                    gpui::AbsoluteLength::Pixels(pixels),
425                ))) => Some(pixels),
426                _ => None,
427            }
428        };
429        assert_eq!(size, Some(px(28.)));
430    }
431
432    #[test]
433    fn component_color_resolves_roles_against_the_active_palette() {
434        let colors = ThemeColors::light();
435        assert_eq!(
436            ComponentColor::Role(Color::Accent).resolve(&colors),
437            colors.accent.color
438        );
439        let literal = oklch(0.2, 0.0, 0.0);
440        assert_eq!(ComponentColor::from(literal).resolve(&colors), literal);
441    }
442}