teksilo_widgets/styles/recipe_spin_box_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SpinBoxStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeSpinBoxStyle::make_body` ports the IntUI spin-box chrome:
7//! a focus-aware bordered rounded rect that wraps `field | divider |
8//! [up / down]`. The field, up button, and down button arrive
9//! pre-built from the widget — the recipe owns the row layout,
10//! the divider between the field and the buttons, the column
11//! arrangement of the two step buttons, and the bordered surface
12//! that frames the whole control as one input.
13//!
14//! Reads the shared text-field dimensions (height, corner radius,
15//! padding, border width) from `recipe_text_input_style` so SpinBox
16//! and `TextInput` sit on the same baseline.
17
18use teksilo_core::build_context::BuildContext;
19use teksilo_core::color_prop::ColorProp;
20use teksilo_core::styles::{ButtonLayout, SharedSpinBoxStyle, SpinBoxStyle, SpinBoxStyleConfig};
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
23
24use crate::primitives::{Divider, Expand, HStack, Padding, RectWidget, VStack, ZStack};
25use crate::styles::recipe_text_input_style as field_dims;
26
27/// Default `SpinBoxStyle` shipped with Teksilo.
28#[derive(Debug, Default, Clone, Copy)]
29pub struct RecipeSpinBoxStyle;
30
31impl SpinBoxStyle for RecipeSpinBoxStyle {
32 fn make_body(&self, cfg: &SpinBoxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
33 // ── Step button column (when not Hidden) ─────────────────
34 let buttons_id_opt: Option<WidgetId> = match cfg.layout {
35 ButtonLayout::Hidden => None,
36 ButtonLayout::Stacked => match (cfg.step_up, cfg.step_down) {
37 (Some(up), Some(down)) => {
38 Some(ctx.add(VStack::new().spacing(0.0).add_child(up).add_child(down)))
39 }
40 _ => None,
41 },
42 };
43
44 // ── Row: field | divider | buttons ────────────────────────
45 // `Expand::horizontal()` defaults to flex=1 with zero-basis: the
46 // wrapped field's natural default does NOT enter the rigid pool,
47 // so the field gets exactly the leftover width inside the
48 // SpinBox's MaxSize-capped bounds.
49 let expanded_field_id = ctx.add(Expand::horizontal().child_id(cfg.field));
50 let row_id = {
51 let mut row = HStack::new().spacing(0.0);
52 row = row.add_child(expanded_field_id);
53 if let Some(buttons_id) = buttons_id_opt {
54 // Thin vertical divider between text and buttons so
55 // the click targets read as distinct affordances. `Field`
56 // so it dims with the rest of the frame — a live rule
57 // inside an inert field reads as a rendering glitch.
58 let divider = Divider::vertical().thickness(1.0).color(BorderRole::Field);
59 let divider_id = ctx.add(Padding::new(2.0, 0.0, 2.0, 0.0).child(divider));
60 row = row.add_child(divider_id).add_child(buttons_id);
61 }
62 ctx.add(row)
63 };
64
65 // Symmetric horizontal padding — same TextInput chrome math
66 // (`padding_horizontal * 2.0`) so SpinBox and TextInput line
67 // up on forms.
68 let padded_row_id = ctx.add(
69 Padding::new(
70 0.0,
71 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
72 0.0,
73 field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
74 )
75 .child_id(row_id),
76 );
77
78 // ── Frame: focus-aware border + background ───────────────
79 // Int UI convention: the focus indicator IS the border —
80 // accent + `focus_ring_width` when focused, default border
81 // color + `border_width` otherwise.
82 let theme = ctx.theme_signal().get();
83 let focus_ring_width = theme.shape.focus_ring_width;
84 let field_border_width = field_dims::TEXT_FIELD_BORDER_WIDTH;
85 // `Field` is `Content`'s twin for *interactive* surfaces: the same
86 // colour while enabled, dimming to `SurfaceRole::Disabled` inside
87 // `ColorProp::resolve` at paint. Resolving there — off the live arena
88 // chain — rather than switching roles from `cfg.is_disabled` is what
89 // lets a SpinBox dim when an *ancestor* is disabled: `is_disabled`
90 // comes from `effective_enabled_signal`, which cannot see ancestors
91 // (a widget's parent is not wired during its own `build()`), so it
92 // only ever reflects the SpinBox's own `enabled` prop.
93 //
94 // The border still consults `is_disabled` so that disabled outranks
95 // *focus*; `Field` covers the resting case.
96 let border_role = cfg.is_focused.zip(&cfg.is_disabled).map(|(f, d)| {
97 if *d {
98 BorderRole::Disabled
99 } else if *f {
100 BorderRole::Focused
101 } else {
102 BorderRole::Field
103 }
104 });
105 let border_width_signal = cfg.is_focused.map(move |f| {
106 if *f {
107 focus_ring_width
108 } else {
109 field_border_width
110 }
111 });
112 let bg = RectWidget::new()
113 .background(SurfaceRole::Field)
114 .border_color(ColorProp::DynamicBorderRole(border_role))
115 .border_width(border_width_signal)
116 .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
117 let bg_id = ctx.add(bg);
118
119 ctx.add(ZStack::new().add_child(bg_id).add_child(padded_row_id))
120 }
121}
122
123/// Convenience for callers that need to resolve the active style
124/// (per-call override → theme slot → default `RecipeSpinBoxStyle`).
125pub fn resolve_spin_box_style(
126 override_: &Option<SharedSpinBoxStyle>,
127 ctx: &BuildContext,
128) -> SharedSpinBoxStyle {
129 if let Some(s) = override_.clone() {
130 return s;
131 }
132 ctx.theme_signal()
133 .get()
134 .style_slots
135 .spin_box
136 .clone()
137 .unwrap_or_else(|| std::rc::Rc::new(RecipeSpinBoxStyle) as SharedSpinBoxStyle)
138}