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