1use crate::tree::{El, Sides, Size};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
14#[non_exhaustive]
15pub enum ComponentSize {
16 Xs,
17 Sm,
18 #[default]
19 Md,
20 Lg,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[non_exhaustive]
26pub enum MetricsRole {
27 Button,
28 IconButton,
29 Input,
30 TextArea,
31 Badge,
32 Card,
33 CardHeader,
34 CardContent,
35 CardFooter,
36 Form,
37 FormItem,
38 Panel,
39 MenuItem,
40 ListItem,
41 PreferenceRow,
42 TableHeader,
43 TableRow,
44 TabTrigger,
45 TabList,
46 ChoiceControl,
47 ChoiceItem,
48 Slider,
49 Progress,
50}
51
52#[derive(Clone, Debug)]
54pub struct ThemeMetrics {
55 default_component_size: ComponentSize,
56 button_size: Option<ComponentSize>,
57 input_size: Option<ComponentSize>,
58 badge_size: Option<ComponentSize>,
59 tab_size: Option<ComponentSize>,
60 choice_size: Option<ComponentSize>,
61 slider_size: Option<ComponentSize>,
62 progress_size: Option<ComponentSize>,
63}
64
65impl ThemeMetrics {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn default_component_size(&self) -> ComponentSize {
71 self.default_component_size
72 }
73
74 pub fn with_default_component_size(mut self, size: ComponentSize) -> Self {
75 self.default_component_size = size;
76 self
77 }
78
79 pub fn with_button_size(mut self, size: ComponentSize) -> Self {
80 self.button_size = Some(size);
81 self
82 }
83
84 pub fn with_input_size(mut self, size: ComponentSize) -> Self {
85 self.input_size = Some(size);
86 self
87 }
88
89 pub fn with_badge_size(mut self, size: ComponentSize) -> Self {
90 self.badge_size = Some(size);
91 self
92 }
93
94 pub fn with_tab_size(mut self, size: ComponentSize) -> Self {
95 self.tab_size = Some(size);
96 self
97 }
98
99 pub fn with_choice_size(mut self, size: ComponentSize) -> Self {
100 self.choice_size = Some(size);
101 self
102 }
103
104 pub fn with_slider_size(mut self, size: ComponentSize) -> Self {
105 self.slider_size = Some(size);
106 self
107 }
108
109 pub fn with_progress_size(mut self, size: ComponentSize) -> Self {
110 self.progress_size = Some(size);
111 self
112 }
113
114 pub(crate) fn apply_to_tree(&self, root: &mut El) {
115 self.apply_to_el(root);
116 for child in &mut root.children {
117 self.apply_to_tree(child);
118 }
119 }
120
121 fn apply_to_el(&self, el: &mut El) {
122 match el.metrics_role {
123 Some(MetricsRole::Button) => {
124 let size = el
125 .component_size
126 .or(self.button_size)
127 .unwrap_or(self.default_component_size);
128 apply_control(el, control_metrics(size, ControlKind::Button));
129 }
130 Some(MetricsRole::IconButton) => {
131 let size = el
132 .component_size
133 .or(self.button_size)
134 .unwrap_or(self.default_component_size);
135 apply_control(el, control_metrics(size, ControlKind::IconButton));
136 }
137 Some(MetricsRole::Input) => {
138 let size = el
139 .component_size
140 .or(self.input_size)
141 .unwrap_or(self.default_component_size);
142 apply_control(el, control_metrics(size, ControlKind::Input));
143 }
144 Some(MetricsRole::TextArea) => {
145 }
149 Some(MetricsRole::Badge) => {
150 let size = el
151 .component_size
152 .or(self.badge_size)
153 .unwrap_or(self.default_component_size);
154 apply_badge(el, badge_metrics(size));
155 }
156 Some(MetricsRole::Card) => {
157 propagate_card_corner_radii(el);
171 }
172 Some(MetricsRole::CardHeader | MetricsRole::CardContent | MetricsRole::CardFooter) => {
173 }
177 Some(
178 MetricsRole::Form
179 | MetricsRole::FormItem
180 | MetricsRole::Panel
181 | MetricsRole::MenuItem
182 | MetricsRole::ListItem
183 | MetricsRole::PreferenceRow
184 | MetricsRole::TableHeader
185 | MetricsRole::TableRow,
186 ) => {
187 }
194 Some(MetricsRole::TabTrigger) => {
195 let size = el
196 .component_size
197 .or(self.tab_size)
198 .unwrap_or(self.default_component_size);
199 apply_control(el, control_metrics(size, ControlKind::Button));
200 }
201 Some(MetricsRole::TabList) => {
202 if let Some(size) = el.component_size {
206 apply_tab_trigger_size_to_children(el, size);
207 }
208 }
209 Some(MetricsRole::ChoiceControl) => {
210 let size = el
211 .component_size
212 .or(self.choice_size)
213 .unwrap_or(self.default_component_size);
214 apply_choice_control(el, choice_control_metrics(size));
215 }
216 Some(MetricsRole::ChoiceItem) => {
217 if let Some(size) = el.component_size {
221 apply_choice_control_size_to_children(el, size);
222 }
223 }
224 Some(MetricsRole::Slider) => {
225 let size = el
226 .component_size
227 .or(self.slider_size)
228 .unwrap_or(self.default_component_size);
229 apply_single_axis_height(el, slider_metrics(size));
230 }
231 Some(MetricsRole::Progress) => {
232 let size = el
233 .component_size
234 .or(self.progress_size)
235 .unwrap_or(self.default_component_size);
236 apply_single_axis_height(el, progress_metrics(size));
237 }
238 None => {}
239 }
240 }
241}
242
243impl Default for ThemeMetrics {
244 fn default() -> Self {
245 Self {
246 default_component_size: ComponentSize::Sm,
252 button_size: None,
253 input_size: None,
254 badge_size: None,
255 tab_size: None,
256 choice_size: None,
257 slider_size: None,
258 progress_size: None,
259 }
260 }
261}
262
263#[derive(Clone, Copy)]
264enum ControlKind {
265 Button,
266 IconButton,
267 Input,
268}
269
270#[derive(Clone, Copy)]
271struct ControlMetrics {
272 height: f32,
273 padding_x: f32,
274 radius: f32,
275 gap: f32,
276}
277
278fn control_metrics(size: ComponentSize, kind: ControlKind) -> ControlMetrics {
279 let (mut height, padding_x, radius, gap): (f32, f32, f32, f32) = match size {
280 ComponentSize::Xs => (28.0, 8.0, 5.0, 4.0),
281 ComponentSize::Sm => (32.0, 10.0, 6.0, 6.0),
282 ComponentSize::Md => (36.0, 12.0, 7.0, 8.0),
283 ComponentSize::Lg => (40.0, 14.0, 8.0, 8.0),
284 };
285 if matches!(kind, ControlKind::Input) && matches!(size, ComponentSize::Lg) {
286 height = 44.0;
287 }
288 match kind {
289 ControlKind::IconButton => ControlMetrics {
290 height,
291 padding_x: 0.0,
292 radius,
293 gap,
294 },
295 ControlKind::Input => ControlMetrics {
296 height,
297 padding_x: padding_x.max(10.0),
298 radius,
299 gap,
300 },
301 ControlKind::Button => ControlMetrics {
302 height,
303 padding_x,
304 radius,
305 gap,
306 },
307 }
308}
309
310fn apply_control(el: &mut El, metrics: ControlMetrics) {
311 if !el.explicit_height {
312 el.height = Size::Fixed(metrics.height);
313 }
314 if matches!(el.metrics_role, Some(MetricsRole::IconButton)) && !el.explicit_width {
315 el.width = Size::Fixed(metrics.height);
316 }
317 if !el.explicit_padding && !matches!(el.metrics_role, Some(MetricsRole::IconButton)) {
318 el.padding = Sides::xy(metrics.padding_x, 0.0);
319 }
320 if !el.explicit_radius {
321 el.radius = crate::tree::Corners::all(metrics.radius);
322 }
323 if !el.explicit_gap {
324 el.gap = metrics.gap;
325 }
326}
327
328#[derive(Clone, Copy)]
329struct BadgeMetrics {
330 height: f32,
331 padding_x: f32,
332}
333
334fn badge_metrics(size: ComponentSize) -> BadgeMetrics {
335 match size {
336 ComponentSize::Xs => BadgeMetrics {
337 height: 18.0,
338 padding_x: 6.0,
339 },
340 ComponentSize::Sm => BadgeMetrics {
341 height: 20.0,
342 padding_x: 7.0,
343 },
344 ComponentSize::Md => BadgeMetrics {
345 height: 24.0,
346 padding_x: 8.0,
347 },
348 ComponentSize::Lg => BadgeMetrics {
349 height: 28.0,
350 padding_x: 10.0,
351 },
352 }
353}
354
355fn apply_badge(el: &mut El, metrics: BadgeMetrics) {
356 if !el.explicit_height {
357 el.height = Size::Fixed(metrics.height);
358 }
359 if !el.explicit_padding {
360 el.padding = Sides::xy(metrics.padding_x, 0.0);
361 }
362}
363
364fn apply_tab_trigger_size_to_children(el: &mut El, size: ComponentSize) {
365 for child in &mut el.children {
366 if matches!(child.metrics_role, Some(MetricsRole::TabTrigger))
367 && child.component_size.is_none()
368 {
369 child.component_size = Some(size);
370 }
371 }
372}
373
374#[derive(Clone, Copy)]
375struct ChoiceControlMetrics {
376 edge: f32,
377}
378
379fn choice_control_metrics(size: ComponentSize) -> ChoiceControlMetrics {
380 let edge = match size {
381 ComponentSize::Xs => 14.0,
382 ComponentSize::Sm => 16.0,
383 ComponentSize::Md => 16.0,
384 ComponentSize::Lg => 18.0,
385 };
386 ChoiceControlMetrics { edge }
387}
388
389fn apply_choice_control(el: &mut El, metrics: ChoiceControlMetrics) {
390 if !el.explicit_width {
391 el.width = Size::Fixed(metrics.edge);
392 }
393 if !el.explicit_height {
394 el.height = Size::Fixed(metrics.edge);
395 }
396}
397
398fn apply_choice_control_size_to_children(el: &mut El, size: ComponentSize) {
399 for child in &mut el.children {
400 if matches!(child.metrics_role, Some(MetricsRole::ChoiceControl))
401 && child.component_size.is_none()
402 {
403 child.component_size = Some(size);
404 }
405 }
406}
407
408fn slider_metrics(size: ComponentSize) -> f32 {
409 match size {
410 ComponentSize::Xs => 14.0,
411 ComponentSize::Sm => 16.0,
412 ComponentSize::Md => 18.0,
413 ComponentSize::Lg => 22.0,
414 }
415}
416
417fn progress_metrics(size: ComponentSize) -> f32 {
418 match size {
419 ComponentSize::Xs => 4.0,
420 ComponentSize::Sm => 6.0,
421 ComponentSize::Md => 8.0,
422 ComponentSize::Lg => 10.0,
423 }
424}
425
426fn apply_single_axis_height(el: &mut El, height: f32) {
427 if !el.explicit_height {
428 el.height = Size::Fixed(height);
429 }
430}
431
432fn propagate_card_corner_radii(card: &mut El) {
452 if !card.radius.any_nonzero() || card.children.is_empty() {
453 return;
454 }
455 let card_radius = card.radius;
456 let pad_top = card.padding.top;
457 let pad_bottom = card.padding.bottom;
458 let last_idx = card.children.len() - 1;
459 for (idx, child) in card.children.iter_mut().enumerate() {
460 if child.fill.is_none() || child.explicit_radius {
461 continue;
462 }
463 match child.metrics_role {
464 Some(MetricsRole::CardHeader) if idx == 0 && pad_top == 0.0 => {
465 child.radius = crate::tree::Corners {
466 tl: card_radius.tl,
467 tr: card_radius.tr,
468 br: 0.0,
469 bl: 0.0,
470 };
471 }
472 Some(MetricsRole::CardFooter) if idx == last_idx && pad_bottom == 0.0 => {
473 child.radius = crate::tree::Corners {
474 tl: 0.0,
475 tr: 0.0,
476 br: card_radius.br,
477 bl: card_radius.bl,
478 };
479 }
480 _ => {}
481 }
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::{button, tabs_list, text_input, titled_card, tokens};
489
490 #[test]
491 fn theme_default_component_size_applies_to_stock_control() {
492 let mut el = button("Save");
493
494 ThemeMetrics::default()
495 .with_default_component_size(ComponentSize::Lg)
496 .apply_to_tree(&mut el);
497
498 assert_eq!(el.height, Size::Fixed(40.0));
499 }
500
501 #[test]
502 fn local_component_size_overrides_theme_default() {
503 let mut el = button("Save").large();
504
505 ThemeMetrics::default()
506 .with_default_component_size(ComponentSize::Xs)
507 .apply_to_tree(&mut el);
508
509 assert_eq!(el.height, Size::Fixed(40.0));
510 }
511
512 #[test]
513 fn input_uses_spacious_field_height_at_large_size() {
514 let mut el = text_input("Search", &crate::Selection::default(), "search").large();
515
516 ThemeMetrics::default().apply_to_tree(&mut el);
517
518 assert_eq!(el.height, Size::Fixed(44.0));
519 }
520
521 #[test]
522 fn explicit_height_overrides_component_metrics() {
523 let mut el = button("Save").height(Size::Fixed(44.0));
524
525 ThemeMetrics::default()
526 .with_default_component_size(ComponentSize::Sm)
527 .apply_to_tree(&mut el);
528
529 assert_eq!(el.height, Size::Fixed(44.0));
530 }
531
532 #[test]
533 fn card_slot_defaults_match_shadcn_stock() {
534 let mut t = titled_card("Settings", [crate::text("Body")]);
538 ThemeMetrics::default().apply_to_tree(&mut t);
539
540 assert_eq!(t.padding, Sides::zero());
542 assert_eq!(t.children[0].padding, Sides::all(tokens::SPACE_6));
544 assert_eq!(
546 t.children[1].padding,
547 Sides {
548 left: tokens::SPACE_6,
549 right: tokens::SPACE_6,
550 top: 0.0,
551 bottom: tokens::SPACE_6,
552 }
553 );
554 }
555
556 #[test]
557 fn card_header_with_fill_inherits_card_top_corner_radii() {
558 use crate::tree::Corners;
559 use crate::{card, card_content, card_header, text};
560 let mut tree = card([
564 card_header([text("Header")]).fill(tokens::MUTED),
565 card_content([text("Body")]),
566 ]);
567 ThemeMetrics::default().apply_to_tree(&mut tree);
568
569 assert_eq!(
570 tree.children[0].radius,
571 Corners {
572 tl: tokens::RADIUS_LG,
573 tr: tokens::RADIUS_LG,
574 br: 0.0,
575 bl: 0.0,
576 },
577 "header strip should adopt the card's top corner radii"
578 );
579 assert_eq!(tree.children[1].radius, Corners::ZERO);
581 }
582
583 #[test]
584 fn card_footer_with_fill_inherits_card_bottom_corner_radii() {
585 use crate::tree::Corners;
586 use crate::{card, card_content, card_footer, text};
587 let mut tree = card([
588 card_content([text("Body")]),
589 card_footer([text("Footer")]).fill(tokens::MUTED),
590 ]);
591 ThemeMetrics::default().apply_to_tree(&mut tree);
592
593 let footer = tree.children.last().expect("footer slot");
594 assert_eq!(
595 footer.radius,
596 Corners {
597 tl: 0.0,
598 tr: 0.0,
599 br: tokens::RADIUS_LG,
600 bl: tokens::RADIUS_LG,
601 }
602 );
603 }
604
605 #[test]
606 fn card_header_explicit_radius_wins_over_inheritance() {
607 use crate::tree::Corners;
608 use crate::{card, card_content, card_header, text};
609 let mut tree = card([
610 card_header([text("Header")])
611 .fill(tokens::MUTED)
612 .radius(Corners::ZERO),
613 card_content([text("Body")]),
614 ]);
615 ThemeMetrics::default().apply_to_tree(&mut tree);
616
617 assert_eq!(
618 tree.children[0].radius,
619 Corners::ZERO,
620 "author override must win over auto-inheritance"
621 );
622 }
623
624 #[test]
625 fn card_header_without_fill_does_not_inherit() {
626 use crate::tree::Corners;
627 use crate::{card, card_content, card_header, text};
628 let mut tree = card([card_header([text("Header")]), card_content([text("Body")])]);
629 ThemeMetrics::default().apply_to_tree(&mut tree);
630 assert_eq!(
631 tree.children[0].radius,
632 Corners::ZERO,
633 "no fill means no corner stackup to fix"
634 );
635 }
636
637 #[test]
638 fn card_with_top_padding_skips_header_inheritance() {
639 use crate::tree::Corners;
640 use crate::{card, card_content, card_header, text};
641 let mut tree = card([
644 card_header([text("Header")]).fill(tokens::MUTED),
645 card_content([text("Body")]),
646 ])
647 .padding(tokens::SPACE_2);
648 ThemeMetrics::default().apply_to_tree(&mut tree);
649 assert_eq!(tree.children[0].radius, Corners::ZERO);
650 }
651
652 #[test]
653 fn theme_tab_size_applies_to_tab_triggers() {
654 let mut el = tabs_list("settings", &"account", [("account", "Account")]);
655
656 ThemeMetrics::default()
657 .with_tab_size(ComponentSize::Lg)
658 .apply_to_tree(&mut el);
659
660 assert_eq!(el.children[0].height, Size::Fixed(40.0));
661 }
662
663 #[test]
664 fn local_tab_list_size_applies_to_tab_triggers() {
665 let mut el =
666 tabs_list("settings", &"account", [("account", "Account")]).size(ComponentSize::Lg);
667
668 ThemeMetrics::default().apply_to_tree(&mut el);
669
670 assert_eq!(el.children[0].height, Size::Fixed(40.0));
671 }
672
673 #[test]
674 fn local_choice_item_size_applies_to_child_control() {
675 let control =
676 El::new(crate::Kind::Custom("choice-control")).metrics_role(MetricsRole::ChoiceControl);
677 let mut el = El::new(crate::Kind::Custom("choice"))
678 .metrics_role(MetricsRole::ChoiceItem)
679 .child(control)
680 .size(ComponentSize::Lg);
681
682 ThemeMetrics::default().apply_to_tree(&mut el);
683
684 assert_eq!(el.children[0].width, Size::Fixed(18.0));
685 assert_eq!(el.children[0].height, Size::Fixed(18.0));
686 }
687
688 #[test]
689 fn progress_size_uses_component_scale() {
690 let mut el = El::new(crate::Kind::Custom("progress")).metrics_role(MetricsRole::Progress);
691
692 ThemeMetrics::default()
693 .with_progress_size(ComponentSize::Sm)
694 .apply_to_tree(&mut el);
695
696 assert_eq!(el.height, Size::Fixed(6.0));
697 }
698
699 #[test]
700 fn raw_metrics_role_tags_no_longer_override_widget_defaults() {
701 for role in [
711 MetricsRole::Form,
712 MetricsRole::FormItem,
713 MetricsRole::ListItem,
714 MetricsRole::MenuItem,
715 MetricsRole::PreferenceRow,
716 MetricsRole::TableRow,
717 MetricsRole::TableHeader,
718 MetricsRole::ChoiceItem,
719 MetricsRole::TextArea,
720 MetricsRole::TabList,
721 MetricsRole::Panel,
722 ] {
723 let mut el = El::new(crate::Kind::Custom("bare")).metrics_role(role);
724 ThemeMetrics::default().apply_to_tree(&mut el);
725 assert_eq!(el.padding, Sides::zero(), "role {role:?} stamped padding");
726 assert_eq!(el.gap, 0.0, "role {role:?} stamped gap");
727 assert_eq!(el.height, Size::Hug, "role {role:?} stamped height");
728 assert_eq!(
729 el.radius,
730 crate::tree::Corners::ZERO,
731 "role {role:?} stamped radius"
732 );
733 }
734 }
735
736 #[test]
737 fn form_constructor_bakes_default_gap() {
738 let mut f = crate::form([crate::form_item([crate::text("body")])]);
741 ThemeMetrics::default().apply_to_tree(&mut f);
742 assert_eq!(f.gap, tokens::SPACE_3);
743 assert_eq!(f.children[0].gap, tokens::SPACE_2);
744 }
745
746 #[test]
747 fn default_metrics_are_compact_desktop_defaults() {
748 let metrics = ThemeMetrics::default();
749
750 assert_eq!(metrics.default_component_size(), ComponentSize::Sm);
751 }
752}