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),
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
70pub trait ComponentStyle: Clone + Default {
72 fn refine(&mut self, overlay: &Self);
74}
75
76#[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 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 SliderStyle { radius: Pixels }
151}
152component_style! {
153 SwitchStyle { radius: Pixels }
155}
156component_style! {
157 SelectStyle {
159 variant: FieldVariant,
160 height: Pixels,
161 padding_x: Pixels,
162 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 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 separator_inset: Pixels,
205 separator_thickness: Pixels,
210 animate_entry: bool,
211 }
212}
213component_style! {
214 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#[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 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 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#[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 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}