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, InputTokens, SurfaceRole};
23
24use crate::primitives::{Divider, Expand, HStack, Padding, RectWidget, VStack, ZStack};
25use crate::styles::TextInputRecipe;
26
27/// Default `SpinBoxStyle` shipped with Teksilo.
28#[derive(Debug, Default, Clone, Copy)]
29pub struct RecipeSpinBoxStyle;
30
31impl RecipeSpinBoxStyle {
32 /// This style resolved against a density's [`InputTokens`], as
33 /// `RecipeSpinBoxStyle::for_tokens(&ctx.theme().input)` at the widget's own
34 /// build site.
35 ///
36 /// The style is a unit struct: it borrows the text-field dimensions
37 /// wholesale, and resolves them per density inside
38 /// [`make_body`](SpinBoxStyle::make_body) from `ctx.theme().input`, so
39 /// there is nothing to bake here.
40 pub fn for_tokens(_tokens: &InputTokens) -> Self {
41 Self
42 }
43}
44
45impl SpinBoxStyle for RecipeSpinBoxStyle {
46 fn make_body(&self, cfg: &SpinBoxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
47 // The field metrics come from the same recipe `TextInput` uses,
48 // resolved against the active density so the two stay on one baseline.
49 let field = TextInputRecipe::for_tokens(&ctx.theme().input);
50
51 // ── Step button column (when not Hidden) ─────────────────
52 let buttons_id_opt: Option<WidgetId> = match cfg.layout {
53 ButtonLayout::Hidden => None,
54 ButtonLayout::Stacked => match (cfg.step_up, cfg.step_down) {
55 (Some(up), Some(down)) => {
56 Some(ctx.add(VStack::new().spacing(0.0).child(up).child(down)))
57 }
58 _ => None,
59 },
60 };
61
62 // ── Row: field | divider | buttons ────────────────────────
63 // `Expand::horizontal()` defaults to flex=1 with zero-basis: the
64 // wrapped field's natural default does NOT enter the rigid pool,
65 // so the field gets exactly the leftover width inside the
66 // SpinBox's MaxSize-capped bounds.
67 let expanded_field_id = ctx.add(Expand::horizontal().child(cfg.field));
68 let row_id = {
69 let mut row = HStack::new().spacing(0.0);
70 row = row.child(expanded_field_id);
71 if let Some(buttons_id) = buttons_id_opt {
72 // Thin vertical divider between text and buttons so
73 // the click targets read as distinct affordances. `Field`
74 // so it dims with the rest of the frame — a live rule
75 // inside an inert field reads as a rendering glitch.
76 let divider = Divider::vertical().thickness(1.0).color(BorderRole::Field);
77 let divider_id = ctx.add(Padding::new(2.0, 0.0, 2.0, 0.0).child(divider));
78 row = row.child(divider_id).child(buttons_id);
79 }
80 ctx.add(row)
81 };
82
83 // Symmetric horizontal padding — same TextInput chrome math
84 // (`padding_horizontal * 2.0`) so SpinBox and TextInput line
85 // up on forms.
86 let padded_row_id = ctx.add(
87 Padding::new(0.0, field.padding_horizontal, 0.0, field.padding_horizontal)
88 .child(row_id),
89 );
90
91 // ── Frame: focus-aware border + background ───────────────
92 // Int UI convention: the focus indicator IS the border —
93 // accent + `focus_ring_width` when focused, default border
94 // color + `border_width` otherwise.
95 let theme = ctx.theme_signal().get();
96 let focus_ring_width = theme.shape.focus_ring_width;
97 let field_border_width = field.border_width;
98 // `Field` is `Content`'s twin for *interactive* surfaces: the same
99 // colour while enabled, dimming to `SurfaceRole::Disabled` inside
100 // `ColorProp::resolve` at paint. Resolving there — off the live arena
101 // chain — rather than switching roles from `cfg.is_disabled` is what
102 // lets a SpinBox dim when an *ancestor* is disabled: `is_disabled`
103 // comes from `effective_enabled_signal`, which cannot see ancestors
104 // (a widget's parent is not wired during its own `build()`), so it
105 // only ever reflects the SpinBox's own `enabled` prop.
106 //
107 // The border still consults `is_disabled` so that disabled outranks
108 // *focus*; `Field` covers the resting case.
109 let border_role = cfg.is_focused.zip(&cfg.is_disabled).map(|(f, d)| {
110 if *d {
111 BorderRole::Disabled
112 } else if *f {
113 BorderRole::Focused
114 } else {
115 BorderRole::Field
116 }
117 });
118 let border_width_signal = cfg.is_focused.map(move |f| {
119 if *f {
120 focus_ring_width
121 } else {
122 field_border_width
123 }
124 });
125 let bg = RectWidget::new()
126 .background(SurfaceRole::Field)
127 .border_color(ColorProp::DynamicBorderRole(border_role))
128 .border_width(border_width_signal)
129 .corner_radius(CornerRadius::uniform(field.corner_radius));
130 let bg_id = ctx.add(bg);
131
132 ctx.add(ZStack::new().child(bg_id).child(padded_row_id))
133 }
134}
135
136/// Convenience for callers that need to resolve the active style
137/// (per-call override → theme slot → default `RecipeSpinBoxStyle`).
138pub fn resolve_spin_box_style(
139 override_: &Option<SharedSpinBoxStyle>,
140 ctx: &BuildContext,
141) -> SharedSpinBoxStyle {
142 if let Some(s) = override_.clone() {
143 return s;
144 }
145 ctx.theme_signal()
146 .get()
147 .style_slots
148 .spin_box
149 .clone()
150 .unwrap_or_else(|| std::rc::Rc::new(RecipeSpinBoxStyle) as SharedSpinBoxStyle)
151}