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 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 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 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#[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 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#[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 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}