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, 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}