Skip to main content

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}