Skip to main content

teksilo_widgets/
spin_box.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SpinBox` — numeric input with increment/decrement buttons.
5//!
6//! A generic composite over [`SpinValue`]
7//! (integer and floating-point primitives), pairing the
8//! [`TextInputField`] editing
9//! primitive with a stacked pair of up/down step buttons. Semantics
10//! are a synthesis of Qt's `QSpinBox` / `QDoubleSpinBox`, WinUI 3's
11//! `NumberBox`, GTK's `GtkSpinButton`, and the W3C ARIA
12//! `spinbutton` role.
13//!
14//! # Behaviour
15//!
16//! - **Value binding**: a `Signal<T>` is the single source of truth.
17//!   Typing and stepping update it; external writes re-format the
18//!   editable text.
19//! - **Commit model**: the user can type freely (subject to the
20//!   per-character input filter, which only the default parser has:
21//!   a custom [`value_from_text`](SpinBox::value_from_text) is handed
22//!   every character). The value is *committed* on
23//!   [`Enter`](teksilo_core::event::Key::Enter) or on focus loss —
24//!   at commit time the text is parsed, clamped into `[min, max]`
25//!   (or wrapped, per [`WrapMode`]), and reformatted. Invalid input
26//!   reverts to the last known good value.
27//! - **Stepping over typed text**: every step (key, wheel, button,
28//!   assistive `Increment` / `Decrement`) starts from what the field
29//!   shows. Text typed and not yet committed is read first, exactly as
30//!   `Enter` would read it, and the step moves on from there: type 35,
31//!   press `Up` with a step of 5, and the value is 40, reported once.
32//!   Text that cannot be read steps nothing; the value goes back into
33//!   the field, and the next press steps from it. This is
34//!   `QAbstractSpinBox::stepBy`.
35//! - **Keyboard**:
36//!   - `Up` / `Down` → ±[`single_step`](SpinBox::single_step)
37//!   - `PageUp` / `PageDown` → ±[`page_step`](SpinBox::page_step)
38//!     (default: `10 × single_step`)
39//!   - `Enter` → commit (stays focused)
40//!   - `Home` / `End` stay bound to the text cursor. `QAbstractSpinBox`
41//!     routes both to its inner `QLineEdit`, and WinUI's `NumberBox`,
42//!     Blink, Avalonia and jQuery UI bind neither; typing the number
43//!     reaches min and max anyway. See `docs/range-keyboard.md`.
44//!   - A chord holding `Ctrl`, `Alt` or `Super` is not the spin box's
45//!     and falls through to the application; `Shift` does not change
46//!     the step.
47//! - **Mouse wheel**: adjusts by `single_step` — wheel **down**
48//!   decreases, wheel **up** increases, matching `QAbstractSpinBox`,
49//!   `GtkSpinButton` and WinUI's `NumberBox`. Gated by
50//!   [`wheel_mode`](SpinBox::wheel_mode) (default: only when
51//!   focused, to avoid accidental scroll changes).
52//! - **Buttons**: up/down buttons stack to the right of the field
53//!   by default; can be hidden with
54//!   [`button_layout`](SpinBox::button_layout).
55//! - **Special value text**: when the current value equals `min`
56//!   and [`special_value_text`](SpinBox::special_value_text) is
57//!   set, the field shows that string instead of the formatted
58//!   number (Qt's "Auto" / "None" / "Unlimited" affordance). It
59//!   stays while the field has focus, as in Qt: keyboard focus
60//!   selects it, so a number typed replaces it.
61//! - **Adaptive step**: with
62//!   [`StepType::Adaptive`], the effective step
63//!   tracks the decimal magnitude of the current value (Qt's
64//!   `AdaptiveDecimalStepType`). Useful for values that span many
65//!   orders of magnitude in the same control.
66//! - **Locale**: the number follows the active locale's decimal
67//!   separator, digits and minus sign
68//!   ([`localized`](SpinBox::localized), on by default); thousands
69//!   separators are opt-in
70//!   ([`use_grouping`](SpinBox::use_grouping), off by default, as in
71//!   Qt). Display, commit parse, stepping and the per-character
72//!   input filter all read one `NumberPresentation`, which a live
73//!   language switch replaces, so they cannot disagree about which
74//!   separator the field is using: a French user sees `12,5`,
75//!   types `12,5`, and the numeric keypad's `.` still works.
76//!   Rendering is a string transform over the value's
77//!   own `Display`, never an `f64` round-trip, so a `SpinBox<i64>`
78//!   stays exact past 2^53. Turn it off for a number that is an
79//!   *identifier* rather than a quantity (port, version component,
80//!   database id). With no `I18nManager` installed the active locale
81//!   is the C locale and this is a no-op.
82//! - **Custom formatter / parser**: full override via
83//!   [`text_from_value`](SpinBox::text_from_value) and
84//!   [`value_from_text`](SpinBox::value_from_text); together they
85//!   let you implement currency, percentages with stored fraction,
86//!   hex, duration, anything. A custom formatter/parser owns the
87//!   whole convention — it is not re-punctuated by the locale layer.
88//!
89//! # Accessibility
90//!
91//! The editing field is the spin button: one AccessKit node with
92//! [`Role::SpinButton`](teksilo_core::accesskit::Role::SpinButton),
93//! the [`label`](SpinBox::label) as its name, the numeric value, min,
94//! max, step and jump, the field's text as its value and its text
95//! runs, and the
96//! [`Increment`](teksilo_core::accesskit::Action::Increment),
97//! [`Decrement`](teksilo_core::accesskit::Action::Decrement),
98//! [`SetValue`](teksilo_core::accesskit::Action::SetValue), and
99//! [`Focus`](teksilo_core::accesskit::Action::Focus) actions. It is
100//! the node that holds focus, so a focus change reports the field's
101//! name and value once, and a step moves the number a screen reader
102//! is following. The composite's own node is structure and collapses;
103//! it names the field as its
104//! [`accessibility_proxy`](teksilo_core::widget::Widget::accessibility_proxy),
105//! so an `access_label`, a `FormLayout` label, an
106//! `access_described_by` or a tooltip given to the spin box lands on
107//! the field. The [`suffix`](SpinBox::suffix) is painted and not part
108//! of the text, so it is not announced.
109//!
110//! `SetValue` accepts either payload shape, because both are sent in
111//! the field: a number (macOS `setAccessibilityValue:` with an
112//! `NSNumber`, AT-SPI's `Value.SetCurrentValue`) is clamped and
113//! published as sent; a string (an `NSString`, the automation
114//! `set_value` tool) takes the same parse `Enter` takes, so a custom
115//! [`value_from_text`](SpinBox::value_from_text) and the locale's
116//! decimal separator are honoured, and an unparseable one reverts the
117//! display and is reported unhandled. A
118//! [`read_only`](SpinBox::read_only) spin box advertises and services
119//! none of the three mutating actions. The step buttons are
120//! structurally part of the SpinBox and publish no separate a11y
121//! nodes; stepping is the spin button's `Increment` and `Decrement`.
122//!
123//! # Example
124//!
125//! ```ignore
126//! use teksilo::widgets::{SpinBox, WrapMode};
127//!
128//! let font_size = ctx.signal(12_i32);
129//! ctx.add(
130//!     SpinBox::new(font_size, 4, 72)
131//!         .single_step(1)
132//!         .page_step(10)
133//!         .suffix(" pt"),
134//! );
135//!
136//! let gain_db = ctx.signal(0.0_f32);
137//! ctx.add(
138//!     SpinBox::new(gain_db, -60.0, 12.0)
139//!         .single_step(0.5)
140//!         .decimals(1)
141//!         .suffix(" dB")
142//!         .wrap_mode(WrapMode::Clamp),
143//! );
144//! ```
145//!
146//! ## Touch and pen
147//!
148//! The step buttons are the controls sweep's one **unreachable target**, and
149//! the reason is recorded rather than papered over. Each is 18 x 13 dp inside a
150//! trailing column exactly its own width and exactly two buttons tall, so a
151//! `Widget::hit_outset` has nowhere to grow (an outset never escapes its
152//! parent); the field beside them takes presses of its own, so the miss-only
153//! slop pass has an eligible bubble owner at distance zero; and two conforming
154//! targets stacked need 88 dp of column, which no density projection of the
155//! field produces. Reaching the floor here is a *layout* change — the desktop
156//! stacked pair replaced by a side-by-side −/+ at coarse densities, as Material
157//! does — and that is a design decision, not a targeting one. The value stays
158//! fully reachable by keyboard (Up/Down, PageUp/PageDown) and by the
159//! `Increment` / `Decrement` assistive actions.
160//!
161//! One thing the sweep did fix. A step still fires on the *press* and arms
162//! hold-to-repeat from it (Qt's `QAbstractSpinBox` convention — there is no
163//! release to start a repeat from), but the repeat now stops on a
164//! `PointerCancel`: a pan claimant winning the press used to leave the box
165//! stepping for the rest of the session, because a cancel is terminal and no
166//! `PointerUp` follows it.
167//!
168//! What the box does about panning, it does by omission. It declares no
169//! [`touch_action`] and makes no pan claim, so its subtree keeps the default
170//! [`TouchAction::AUTO`] and a finger that comes to rest on it and then drags
171//! is won by the enclosing scroller rather than changing the value. The
172//! `on_scroll` handler below is a *wheel* handler and not a pan; it never sees
173//! a finger.
174//!
175//! Two tests in `spin_box/tests.rs` cover that, and only one of them can see
176//! the second half of it. `a_finger_pan_over_the_spin_box_scrolls_its_container`
177//! shows the pan reaching the scroller, but its box has the default
178//! [`WheelMode::Focused`] and its fixture never focuses the field, so the
179//! `on_scroll` below declines before the pan question is reached — a pan that
180//! DID arrive would leave the value unchanged there anyway.
181//! `a_finger_pan_over_a_hover_wheel_spin_box_scrolls_its_container` is the one
182//! that can observe the claim: with [`WheelMode::Hover`] the handler runs for
183//! every scroll that reaches the box, so a claim would step the value once per
184//! synthesised sample.
185//!
186//! P22, the scrollables migration, looked at the explicit
187//! `touch_action(PAN_Y)` this file was asked for and **decided against it**.
188//! It is a *narrowing* on top of the behaviour above rather than a restatement
189//! of it: it would additionally forbid a horizontal pan and a pinch through
190//! the field — a real cost to a spin box in a horizontally-scrolling toolbar,
191//! or on a pinch-zoomable page — and it would buy nothing the omission does
192//! not already give, because the claimant chain visits only the pan candidates
193//! and never the generic bubble, so a boundary pan cannot reach the `on_scroll`
194//! below however far it travels. The omission is the decision; the
195//! hover-wheel test named above is what can witness it. P24 owns
196//! `spin_box/step_button.rs`.
197//!
198//! [`touch_action`]: teksilo_core::widget_builder::WidgetBuilder::touch_action
199//! [`TouchAction::AUTO`]: teksilo_core::pointer::touch_action::TouchAction::AUTO
200
201mod step_button;
202#[cfg(test)]
203mod tests;
204mod value;
205
206use std::cell::RefCell;
207use std::rc::Rc;
208
209pub use self::value::SpinValue;
210
211use teksilo_canvas::{Path, Point, Rect, Size, SizeProposal};
212use teksilo_core::accessibility::AccessNodeBuilder;
213use teksilo_core::build_context::BuildContext;
214use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
215use teksilo_core::signal::{Prop, Signal};
216use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
217use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
218use teksilo_core::widget_id::WidgetId;
219use teksilo_text::SharedTypesetter;
220use teksilo_tokens::{CornerRadius, InputTokens, TargetRole, TextStyle};
221
222use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
223use crate::primitives::icon_widget::IconWidget;
224use crate::primitives::text_input_field::{TextFieldHandle, TextInputField};
225use crate::primitives::{MinSize, Padding};
226
227use self::step_button::StepButton;
228
229// ── Enums ──────────────────────────────────────────────────────────
230
231/// Out-of-range behavior when stepping past `min` or `max`.
232///
233/// Set via [`SpinBox::wrap_mode`].
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum WrapMode {
236    /// Clamp to `min` / `max` (default).
237    #[default]
238    Clamp,
239    /// Wrap around: past `max` jumps to `min`, past `min` jumps to
240    /// `max`. Matches Qt's `QAbstractSpinBox::wrapping`.
241    Wrap,
242}
243
244/// Step-size policy for each key/button press.
245///
246/// Set via [`SpinBox::step_type`].
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub enum StepType {
249    /// Always step by `single_step` (default).
250    #[default]
251    Fixed,
252    /// Step by the decimal power-of-ten immediately below the
253    /// current value's magnitude — e.g. values 1–9 step by 1,
254    /// 10–99 by 10, 100–999 by 100. Matches Qt's
255    /// `AdaptiveDecimalStepType`. Integer types honor the same
256    /// rule using the magnitude of the absolute value.
257    Adaptive,
258}
259
260pub use teksilo_core::styles::ButtonLayout;
261use teksilo_core::styles::density::dp;
262use teksilo_i18n::LocalizedString;
263
264/// When the mouse wheel is allowed to adjust the value.
265///
266/// Set via [`SpinBox::wheel_mode`].
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268pub enum WheelMode {
269    /// Wheel adjusts only when the field is focused. Default —
270    /// prevents accidental changes when the user is scrolling a
271    /// larger surrounding view.
272    #[default]
273    Focused,
274    /// Wheel adjusts whenever the pointer is over the widget.
275    Hover,
276    /// Wheel never adjusts the value; events bubble to the
277    /// surrounding scroll container.
278    Disabled,
279}
280
281/// How the SpinBox decides its horizontal size envelope.
282///
283/// Chosen via the [`width`](SpinBox::width),
284/// [`width_chars`](SpinBox::width_chars), and
285/// [`fill_width`](SpinBox::fill_width) builder methods — the enum
286/// itself is the storage, not a separate public configuration
287/// API.
288#[derive(Debug, Clone)]
289pub enum WidthPolicy {
290    /// Cap the widget at a fixed logical-pixel width. Default is
291    /// `DEFAULT_PREFERRED_WIDTH` (120 dp), matching Qt's
292    /// `QSpinBox` sizeHint.
293    Pixels(f32),
294    /// Size the widget to fit this many reference digits (`'0'`)
295    /// plus the configured suffix, padding, and step buttons.
296    /// Measurement uses the theme font at build time.
297    Chars(u32),
298    /// Let the widget expand horizontally to fill whatever space
299    /// the parent offers. Equivalent to an infinite pixel cap.
300    Fill,
301}
302
303// ── Type aliases for builder closures ──────────────────────────────
304
305type TextFromValue<T> = Rc<dyn Fn(T) -> LocalizedString>;
306type ValueFromText<T> = Rc<dyn Fn(&str) -> Option<T>>;
307type OnValueChangedFn<T> = Rc<dyn Fn(T, &mut EventContext)>;
308
309/// What one step did, for a caller that has an `EventContext` to report it
310/// with.
311enum StepOutcome<T> {
312    /// Nothing moved and the field already showed the value.
313    Unchanged,
314    /// The value stayed, and the field was put back to showing it over text
315    /// the user had typed.
316    Restored,
317    /// The value moved to this.
318    Changed(T),
319}
320
321/// Minimum total width. Below this the stacked step buttons stop
322/// fitting next to the field. Widgets narrower than this are
323/// enforced to the minimum at layout time via `MinSize`.
324/// Painted width of one stacked step button, in dp.
325///
326/// Below the 24 dp WCAG floor, and **no hit mechanism reaches it** — see the
327/// "Touch and pen" section of this module and `StepButton::children` for the
328/// measurement. The two steps are separate nodes stacked in a trailing column
329/// exactly one button wide, so there is nothing for `Widget::hit_outset` to
330/// grow into and nothing for `partition_targets` to carve; growing the paint
331/// instead would widen every numeric field on a form. Named here so the hit
332/// mechanisms and the `chrome` arithmetic below read the same number.
333pub const SPIN_BOX_STEP_BUTTON_WIDTH: f32 = 18.0;
334
335/// Painted height of one stacked step button, in dp. Only a fallback — the
336/// real height is half the field's inner height (see `build_step_buttons`).
337pub const SPIN_BOX_STEP_BUTTON_HEIGHT: f32 = 12.0;
338
339const MIN_WIDTH_WITH_BUTTONS: f32 = 72.0;
340
341/// [`MIN_WIDTH_WITH_BUTTONS`] raised to the density's `target_size`
342/// (24 / 32 / 44 dp). The identity at Compact.
343fn min_width_with_buttons(tokens: &InputTokens) -> f32 {
344    dp(MIN_WIDTH_WITH_BUTTONS, TargetRole::Target, tokens)
345}
346/// Minimum total width when buttons are hidden — the field alone
347/// plus padding still reads as a numeric control.
348const MIN_WIDTH_NO_BUTTONS: f32 = 48.0;
349
350/// [`MIN_WIDTH_NO_BUTTONS`] raised to the density's `target_size`
351/// (24 / 32 / 44 dp). The identity at Compact.
352fn min_width_no_buttons(tokens: &InputTokens) -> f32 {
353    dp(MIN_WIDTH_NO_BUTTONS, TargetRole::Target, tokens)
354}
355/// Default maximum width. Matches Qt's `QSpinBox` sizeHint for a
356/// 4-digit value + unit suffix and stays tight in Int UI-style
357/// dense forms.
358const DEFAULT_PREFERRED_WIDTH: f32 = 120.0;
359
360/// [`DEFAULT_PREFERRED_WIDTH`] raised to the density's `target_size`
361/// (24 / 32 / 44 dp). The identity at Compact.
362fn default_preferred_width(tokens: &InputTokens) -> f32 {
363    dp(DEFAULT_PREFERRED_WIDTH, TargetRole::Target, tokens)
364}
365
366// ── SpinBox ────────────────────────────────────────────────────────
367
368/// Numeric input with step buttons. Generic over
369/// [`SpinValue`] — pre-implemented for `i32`, `i64`, `u32`, `u64`,
370/// `usize`, `f32`, and `f64`.
371pub struct SpinBox<T: SpinValue> {
372    // ── Required configuration ──────────────────────────────────────
373    value: Signal<T>,
374    min: T,
375    max: T,
376
377    // ── Optional configuration (builders) ───────────────────────────
378    single_step: T,
379    page_step: Option<T>,
380    decimals: u8,
381    suffix: String,
382    /// Whether the displayed number follows the active locale's
383    /// conventions. See [`localized`](SpinBox::localized).
384    localized: bool,
385    /// Whether the displayed number carries thousands separators.
386    /// Off by default — see [`use_grouping`](SpinBox::use_grouping).
387    use_grouping: bool,
388    special_value_text: Option<LocalizedString>,
389    wrap_mode: WrapMode,
390    step_type: StepType,
391    button_layout: ButtonLayout,
392    wheel_mode: WheelMode,
393    /// Horizontal sizing policy. One of [`WidthPolicy::Pixels`]
394    /// (fixed cap), [`WidthPolicy::Chars`] (font-metric-based),
395    /// or [`WidthPolicy::Fill`] (stretch to parent). Set by the
396    /// [`width`](SpinBox::width), [`width_chars`](SpinBox::width_chars),
397    /// and [`fill_width`](SpinBox::fill_width) builder methods.
398    width_policy: WidthPolicy,
399    label: Option<LocalizedString>,
400    placeholder: LocalizedString,
401    /// Enabled state, static or reactive; forwarded to the arena at
402    /// build time. Also captured as a build-time snapshot for the two
403    /// build-time decisions inside `build()` that need a plain `bool`
404    /// (seeding the inner `TextInputField`'s own `enabled` prop, and
405    /// deriving the step buttons' enabled signals). The key-preview
406    /// and scroll handlers deliberately do NOT read it — the
407    /// dispatcher gates on `arena.is_enabled` first, and a build-time
408    /// snapshot would leave a `Signal`-enabled spin box with a dead
409    /// keyboard and wheel.
410    enabled: Prop<bool>,
411    read_only: bool,
412    text_from_value: Option<TextFromValue<T>>,
413    value_from_text: Option<ValueFromText<T>>,
414    on_value_changed: Option<OnValueChangedFn<T>>,
415
416    // ── Internal state (set during build) ───────────────────────────
417    text_signal: Signal<String>,
418    /// Tracks whether any descendant of the SpinBox root holds focus —
419    /// in practice, the inner `TextInputField`. Driven by
420    /// `WidgetBuilder::focus_within` on the root frame; replaces the
421    /// previous multi-state `InteractionState` signal that was piped
422    /// in/out of the field via `interaction_signal()`. The outer SpinBox
423    /// only ever cared about Focused vs not.
424    focused: Signal<bool>,
425    can_step_up: Signal<bool>,
426    can_step_down: Signal<bool>,
427    /// Cached horizontal cap in pixels, resolved from `width_policy`
428    /// at build time (Chars mode measures the theme font). `None`
429    /// when the policy is `Fill`. Applied by `size_that_fits` by
430    /// narrowing the proposal before delegating to the child — this
431    /// replaces wrapping the subtree in a `MaxSize`, which clips
432    /// children and would truncate the focus-state border stroke
433    /// against its own shape quad.
434    pixel_cap: Option<f32>,
435    /// Floor width so the field and step buttons always fit. Also
436    /// resolved at build from `button_layout`.
437    min_width: f32,
438    /// Per-call style override for the SpinBox chrome. Higher
439    /// precedence than the theme-wide `style_slots.spin_box` slot.
440    style_override: Option<teksilo_core::styles::SharedSpinBoxStyle>,
441    root_child_id: Option<WidgetId>,
442    field_id: Option<WidgetId>,
443
444    // ── Tooltip slots (mutually exclusive; last setter wins) ─────────
445    /// Optional plain tooltip text shown after a hover delay. Mutually
446    /// exclusive with the rich / composite slots — every setter clears
447    /// the other two so the last call wins.
448    tooltip_text: Option<LocalizedString>,
449    /// Optional rich tooltip source (registry key or inline content).
450    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
451    /// Optional composite tooltip body (arbitrary widget tree).
452    composite_tooltip_content: Option<Box<dyn Widget>>,
453}
454
455impl<T: SpinValue> std::fmt::Debug for SpinBox<T> {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        f.debug_struct("SpinBox")
458            .field("min", &self.min)
459            .field("max", &self.max)
460            .field("single_step", &self.single_step)
461            .field("decimals", &self.decimals)
462            .field("localized", &self.localized)
463            .field("use_grouping", &self.use_grouping)
464            .field("wrap_mode", &self.wrap_mode)
465            .finish_non_exhaustive()
466    }
467}
468
469impl<T: SpinValue> SpinBox<T> {
470    /// Construct a new SpinBox bound to `value` with the given
471    /// inclusive range. `min` must be ≤ `max`.
472    pub fn new(value: Signal<T>, min: T, max: T) -> Self {
473        // `single_step` defaults to 1 on the value's f64 scale —
474        // works naturally for integers and for decimal floats.
475        // Callers with a different natural step (0.1, 0.5, 10, …)
476        // override via `single_step(...)`.
477        let default_step = T::from_f64_saturating(1.0);
478        Self {
479            value,
480            min,
481            max,
482            single_step: default_step,
483            page_step: None,
484            decimals: if T::is_integer() { 0 } else { 2 },
485            suffix: String::new(),
486            localized: true,
487            use_grouping: false,
488            special_value_text: None,
489            wrap_mode: WrapMode::Clamp,
490            step_type: StepType::Fixed,
491            button_layout: ButtonLayout::Stacked,
492            wheel_mode: WheelMode::Focused,
493            width_policy: WidthPolicy::Pixels(DEFAULT_PREFERRED_WIDTH),
494            label: None,
495            placeholder: LocalizedString::literal(String::new()),
496            enabled: Prop::Static(true),
497            read_only: false,
498            text_from_value: None,
499            value_from_text: None,
500            on_value_changed: None,
501            text_signal: Signal::new(String::new()),
502            focused: Signal::new(false),
503            can_step_up: Signal::new(true),
504            can_step_down: Signal::new(true),
505            pixel_cap: None,
506            min_width: MIN_WIDTH_WITH_BUTTONS,
507            style_override: None,
508            root_child_id: None,
509            field_id: None,
510            tooltip_text: None,
511            rich_tooltip_source: None,
512            composite_tooltip_content: None,
513        }
514    }
515
516    /// Per-call style override. Higher precedence than the theme-wide
517    /// `style_slots.spin_box` slot.
518    pub fn style(mut self, style: impl teksilo_core::styles::SpinBoxStyle) -> Self {
519        self.style_override = Some(Rc::new(style));
520        self
521    }
522
523    // ── Builder methods ─────────────────────────────────────────────
524
525    /// Set the step size for `Up` / `Down` / single wheel tick /
526    /// button tap.
527    pub fn single_step(mut self, step: T) -> Self {
528        self.single_step = step;
529        self
530    }
531
532    /// Set the step size for `PageUp` / `PageDown`. When unset,
533    /// defaults to `10 × single_step` at build time.
534    pub fn page_step(mut self, step: T) -> Self {
535        self.page_step = Some(step);
536        self
537    }
538
539    /// Number of decimal places shown for floating-point types.
540    /// Ignored for integer types.
541    pub fn decimals(mut self, decimals: u8) -> Self {
542        self.decimals = decimals;
543        self
544    }
545
546    /// Whether the number follows the active locale's conventions —
547    /// decimal separator, digits, and minus sign. **On by default.**
548    ///
549    /// A French user sees `12,5`, not `12.5`, and can type either: the
550    /// commit path de-localizes before parsing, and the input filter
551    /// accepts both the locale's separator and the ASCII one, so a
552    /// numeric keypad still works.
553    ///
554    /// Turn it **off** for a number that is an identifier rather than a
555    /// quantity — a port number, a version component, a database id, a
556    /// pixel offset in a file format. Those read wrong grouped or
557    /// re-punctuated, and their conventional form is the C-locale one.
558    ///
559    /// Localization is a string transform over the value's own
560    /// `Display`, not a round-trip through `f64`, so a `SpinBox<i64>`
561    /// keeps full precision past 2^53.
562    ///
563    /// With no `I18nManager` installed the active locale resolves to the
564    /// C locale, so this is a no-op in tests and in apps that have not
565    /// opted into i18n.
566    pub fn localized(mut self, on: bool) -> Self {
567        self.localized = on;
568        self
569    }
570
571    /// Whether the displayed number carries thousands separators.
572    /// **Off by default**, matching Qt (`QAbstractSpinBox::
573    /// isGroupSeparatorShown` is false unless asked for).
574    ///
575    /// Separators help a large read-only quantity and get in the way of
576    /// a field being typed into, so this is opt-in per SpinBox rather
577    /// than a locale-wide default. Grouping follows the locale's own
578    /// group sizes, including the Indic lakh system (`12,34,567`).
579    ///
580    /// Has no effect when [`localized`](Self::localized) is off.
581    pub fn use_grouping(mut self, on: bool) -> Self {
582        self.use_grouping = on;
583        self
584    }
585
586    /// Qt-style non-editable trailing unit (e.g. `" %"`, `" px"`,
587    /// `" dB"`). Rendered flush-right inside the field's border;
588    /// the caret cannot enter it.
589    pub fn suffix(mut self, text: impl Into<String>) -> Self {
590        self.suffix = text.into();
591        self
592    }
593
594    /// Text shown in place of the formatted value when the current
595    /// value equals `min`. Use for "Auto", "None", "Off",
596    /// "Unlimited" affordances where the minimum has special
597    /// semantics. The text stays while the field has focus, as
598    /// Qt's `specialValueText` does: keyboard focus selects it, so a
599    /// number typed replaces it, and a step moves off it.
600    pub fn special_value_text(mut self, text: impl Into<LocalizedString>) -> Self {
601        self.special_value_text = Some(text.into());
602        self
603    }
604
605    /// Set the out-of-range behavior when stepping past `min` or `max`
606    /// (default: `Clamp`).
607    pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
608        self.wrap_mode = mode;
609        self
610    }
611
612    /// Set the step-size policy (default: `Fixed`). Use
613    /// `StepType::Adaptive` for values that span many orders of magnitude.
614    pub fn step_type(mut self, step_type: StepType) -> Self {
615        self.step_type = step_type;
616        self
617    }
618
619    /// Override the step-button layout (default: `Stacked` — stacked
620    /// up/down buttons to the right of the field).
621    pub fn button_layout(mut self, layout: ButtonLayout) -> Self {
622        self.button_layout = layout;
623        self
624    }
625
626    /// Convenience wrapper over [`button_layout`](Self::button_layout):
627    /// `true` → `ButtonLayout::Stacked`, `false` → `ButtonLayout::Hidden`.
628    /// Matches the Int UI guideline that SpinBoxes in dense forms
629    /// often hide the step buttons to reduce visual noise and let
630    /// keyboard / wheel carry the affordance — pass
631    /// `.show_buttons(false)` on those call sites.
632    pub fn show_buttons(mut self, show: bool) -> Self {
633        self.button_layout = if show {
634            ButtonLayout::Stacked
635        } else {
636            ButtonLayout::Hidden
637        };
638        self
639    }
640
641    /// Set when the mouse wheel adjusts the value (default: `Focused` —
642    /// only when the inner field holds focus).
643    pub fn wheel_mode(mut self, mode: WheelMode) -> Self {
644        self.wheel_mode = mode;
645        self
646    }
647
648    /// Cap the widget's horizontal size at a fixed logical-pixel
649    /// width. If the parent offers less, the SpinBox shrinks (down
650    /// to the internal 72 dp / 48 dp floor that keeps the buttons
651    /// and field from overlapping). Default: 120 dp, matching Qt
652    /// `QSpinBox` sizeHint and Int UI form density.
653    ///
654    /// ```rust
655    /// # use teksilo_widgets::SpinBox;
656    /// # use teksilo_core::signal::Signal;
657    /// # let v = Signal::new(0_i32);
658    /// let _w = SpinBox::new(v.clone(), 0, 9999).width(80.0);        // narrow
659    /// let _w = SpinBox::new(v.clone(), 0, 9999).width(200.0);       // wider
660    /// let _w = SpinBox::new(v.clone(), 0, 9999).fill_width();       // stretch to parent
661    /// let _w = SpinBox::new(v.clone(), 0, 9999).width_chars(5);     // "fits 5 digits"
662    /// ```
663    pub fn width(mut self, width: f32) -> Self {
664        self.width_policy = WidthPolicy::Pixels(width.max(0.0));
665        self
666    }
667
668    /// Size the widget to fit exactly `chars` reference digits plus
669    /// the configured suffix, padding, and step buttons. The
670    /// measurement uses the actual theme font at build time (same
671    /// `SharedTypesetter` the field draws with), so values stay
672    /// right under runtime theme switches and HiDPI scale changes.
673    ///
674    /// ```rust
675    /// # use teksilo_widgets::SpinBox;
676    /// # use teksilo_core::signal::Signal;
677    /// # let port = Signal::new(8080_i32);
678    /// # let pct = Signal::new(0_i32);
679    /// let _w = SpinBox::new(port, 0, 65_535).width_chars(5);           // 5 digits
680    /// let _w = SpinBox::new(pct, 0, 100).suffix(" %").width_chars(3);  // 3 + " %"
681    /// ```
682    pub fn width_chars(mut self, chars: u32) -> Self {
683        self.width_policy = WidthPolicy::Chars(chars);
684        self
685    }
686
687    /// Let the widget expand to fill the horizontal space offered
688    /// by its parent, instead of capping at [`width`](Self::width).
689    /// Use inside toolbars, inspector panels, or an
690    /// `Expand::horizontal` column that should stretch with the
691    /// surrounding layout.
692    pub fn fill_width(mut self) -> Self {
693        self.width_policy = WidthPolicy::Fill;
694        self
695    }
696
697    /// Set the accessible name announced by screen readers as the
698    /// control's label. ARIA requires spin buttons to have a label;
699    /// when none is set here the caller is responsible for labelling
700    /// via a `FormLayout` line, `access_labelled_by` or `access_label`
701    /// on the spin box, which all reach the editing field that holds
702    /// focus and publishes the spin button.
703    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
704        let ls: LocalizedString = label.into();
705        self.label = Some(ls);
706        self
707    }
708
709    /// Set the placeholder text shown in the field when it is empty.
710    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
711        let ls: LocalizedString = text.into();
712        self.placeholder = ls;
713        self
714    }
715
716    /// Set the enabled state, statically or reactively. Forwarded to
717    /// the arena at build time via
718    /// `ctx.enabled_when(spinbox_id, self.enabled.clone())`.
719    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
720        self.enabled = enabled.into();
721        self
722    }
723
724    /// Make the value uneditable: no typing, no keyboard or wheel stepping,
725    /// no step buttons, and no assistive-technology `Increment` / `Decrement`
726    /// / `SetValue`. Matches `QAbstractSpinBox::readOnly`, which likewise
727    /// stops `stepBy`. The field keeps focus and selection, so the value can
728    /// still be read and copied.
729    pub fn read_only(mut self, read_only: bool) -> Self {
730        self.read_only = read_only;
731        self
732    }
733
734    /// Override the value → display-string conversion. Receives the
735    /// raw value; returns whatever string should appear in the
736    /// field. Suffix and `special_value_text` still apply on top of
737    /// the returned string.
738    pub fn text_from_value(mut self, f: impl Fn(T) -> LocalizedString + 'static) -> Self {
739        self.text_from_value = Some(Rc::new(f));
740        self
741    }
742
743    /// Override the parse step. Receives the field's raw text
744    /// (without the suffix, which is never part of the editable
745    /// content); returns `Some(value)` to accept or `None` to
746    /// reject. Invalid input reverts to the last good value on
747    /// commit.
748    ///
749    /// Installing a parser also lifts the per-character input filter,
750    /// which admits only what the default numeric parse can read. The
751    /// parser owns the text convention, so a month field reading
752    /// `"march"` as 3 can be typed by name: every character reaches the
753    /// field, and whatever the parser refuses is reverted at commit.
754    pub fn value_from_text(mut self, f: impl Fn(&str) -> Option<T> + 'static) -> Self {
755        self.value_from_text = Some(Rc::new(f));
756        self
757    }
758
759    /// Closure fired each time the value is committed (keyboard
760    /// step, button tap, wheel tick, Enter, blur). Bound observers
761    /// on the value signal also see every change; use this hook
762    /// when the caller needs an `EventContext` (e.g. to fire an
763    /// intent).
764    pub fn on_value_changed(mut self, f: impl Fn(T, &mut EventContext) + 'static) -> Self {
765        self.on_value_changed = Some(Rc::new(f));
766        self
767    }
768
769    // ── Tooltip builder methods ─────────────────────────────────────
770
771    /// Attach a plain single-line tooltip shown after a hover delay.
772    ///
773    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
774    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
775    /// [`composite_tooltip`](Self::composite_tooltip) — each setter
776    /// clears the other two so the last call wins.
777    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
778        self.tooltip_text = Some(text.into());
779        self.rich_tooltip_source = None;
780        self.composite_tooltip_content = None;
781        self
782    }
783
784    /// Attach a rich tooltip looked up by registry key.
785    ///
786    /// The key must match a [`TooltipContent`](crate::tooltip::TooltipContent)
787    /// registered in the application's tooltip registry. Mutually
788    /// exclusive with [`tooltip`](Self::tooltip),
789    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
790    /// [`composite_tooltip`](Self::composite_tooltip).
791    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
792        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
793        self.tooltip_text = None;
794        self.composite_tooltip_content = None;
795        self
796    }
797
798    /// Attach a rich tooltip with inline content (no registry key
799    /// required). Mutually exclusive with [`tooltip`](Self::tooltip),
800    /// [`rich_tooltip`](Self::rich_tooltip), and
801    /// [`composite_tooltip`](Self::composite_tooltip).
802    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
803        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
804        self.tooltip_text = None;
805        self.composite_tooltip_content = None;
806        self
807    }
808
809    /// Attach a composite tooltip whose body is an arbitrary widget
810    /// tree. Mutually exclusive with [`tooltip`](Self::tooltip),
811    /// [`rich_tooltip`](Self::rich_tooltip), and
812    /// [`rich_tooltip_content`](Self::rich_tooltip_content).
813    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
814        self.composite_tooltip_content = Some(Box::new(content));
815        self.tooltip_text = None;
816        self.rich_tooltip_source = None;
817        self
818    }
819
820    /// Like [`composite_tooltip`](Self::composite_tooltip) but accepts
821    /// an already-boxed widget body. Used by wrapper widgets that
822    /// forward a boxed composite body.
823    pub(crate) fn composite_tooltip_boxed(mut self, content: Box<dyn Widget>) -> Self {
824        self.composite_tooltip_content = Some(content);
825        self.tooltip_text = None;
826        self.rich_tooltip_source = None;
827        self
828    }
829
830    // ── Signal accessors (call before add to tree) ──────────────────
831
832    /// The bound numeric value signal.
833    pub fn value(&self) -> Signal<T> {
834        self.value.clone()
835    }
836}
837
838impl<T: SpinValue> Widget for SpinBox<T> {
839    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
840        // Sanity-check the range once. A malformed range is a
841        // programming error, not a runtime user error.
842        debug_assert!(self.min <= self.max, "SpinBox min must be <= max");
843
844        // SpinBox reads theme tokens once for static layout params
845        // (padding, focus-ring width, body typography). Colors resolve
846        // through role props against the current theme at paint time,
847        // so runtime theme switches re-paint without riding through
848        // per-widget zips.
849        //
850        // The snapshot is OWNED (`theme_signal.get()` clones), because the
851        // body typography is read further down in `build()` — past many
852        // `ctx.add(...)` / `ctx.effect(...)` calls that need a mutable
853        // borrow of `ctx`. A `ctx.theme()` borrow would hold `ctx`
854        // immutable across those calls and fail the borrow checker.
855        let theme = ctx.theme_signal().get();
856        use crate::styles::recipe_text_input_style as field_dims;
857        let field_border_width = field_dims::TEXT_FIELD_BORDER_WIDTH;
858        let focus_ring_width = theme.shape.focus_ring_width;
859
860        // Capture configuration into owned clones for the effect
861        // closures. The builder closures are `Rc`-wrapped already.
862        let min = self.min;
863        let max = self.max;
864        let decimals = self.decimals;
865        let suffix_str = self.suffix.clone();
866        let special_text = self.special_value_text.clone();
867        let text_from_value = self.text_from_value.clone();
868        let value_from_text = self.value_from_text.clone();
869        let wrap_mode = self.wrap_mode;
870        let step_type = self.step_type;
871        let single_step = self.single_step;
872        let page_step = self
873            .page_step
874            .unwrap_or_else(|| single_step.saturating_mul_u32(10));
875        let on_value_changed = self.on_value_changed.clone();
876        let self_id = ctx.self_id();
877        // Forward the enabled state into the arena; see IconButton.
878        ctx.enabled_when(self_id, self.enabled.clone());
879        // Snapshot the build-time enabled state into a local for
880        // closures that capture it for read-only-style guards on
881        // wheel events / step buttons. The framework's event gate
882        // already refuses to dispatch to disabled subtrees, so the
883        // value here only matters for the few build-time decisions
884        // (e.g. seeding the inner TextInputField's read_only mode).
885        let enabled = self.enabled.get();
886        let read_only = self.read_only;
887        let wheel_mode = self.wheel_mode;
888
889        // The locale's number conventions, and the *same* value for the
890        // display path, the commit parse, the step and the input filter.
891        // Split resolution would let them disagree about which separator
892        // this field uses, which `DateEdit` avoids by deriving format, parse
893        // and mask from one `ParsedPattern`.
894        //
895        // A cell every path reads as it runs, not a copy each captures. A
896        // locale switch rebuilds nothing (`WidgetTree::set_locale` marks the
897        // tree dirty and stops there), so a copy taken here kept every path
898        // but the re-render in the build-time locale: after a switch to
899        // French the field showed `12,5`, dropped the comma typed into it,
900        // and committed 125. The effect on `ctx.locale_signal()` below
901        // replaces the value in the step that re-renders the text, so the
902        // text shown and the conventions it is read with are one locale's.
903        let presentation = LivePresentation::new(NumberPresentation::resolve(
904            self.localized,
905            self.use_grouping,
906        ));
907
908        // A handle on the editing field, handed out before the field exists
909        // (it is built further down) so the commit and step closures can
910        // read what the field holds rather than the signal it mirrors into
911        // a frame late. `share_handle` binds it when the field is built.
912        let field_handle = TextFieldHandle::detached();
913
914        // Every string this widget puts in the field goes through `show`,
915        // which remembers it. The field's text is the user's to edit, so
916        // whatever it holds that differs from `last_shown` was typed. That
917        // comparison is exact whatever the locale, the formatter or the
918        // special-value text made of the number: telling typing from the
919        // widget's own output never means rendering the value again and
920        // hoping it comes out the same.
921        //
922        // The write is unconditional. The bound signal can still hold this
923        // very string while the field shows something typed since (typing
924        // reaches the signal a frame late), and only a write makes the field
925        // replace its text; the field compares before it rewrites, so a
926        // redundant write costs one string comparison.
927        let last_shown: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
928        let show: Rc<dyn Fn(String)> = {
929            let text_signal = self.text_signal.clone();
930            let last_shown = last_shown.clone();
931            Rc::new(move |text: String| {
932                last_shown.replace(text.clone());
933                text_signal.set(text);
934            })
935        };
936
937        // Seed the text signal from the current value.
938        show(format_for_display(
939            self.value.get(),
940            decimals,
941            special_text.as_ref(),
942            text_from_value.as_deref(),
943            min,
944            &presentation.get(),
945        ));
946
947        // Effect: when the value signal changes externally, reformat
948        // the text. This also fires on startup with the initial value
949        // (guaranteed by `ctx.effect`). Skipped when the field is
950        // focused so typing isn't interrupted by our own round-trip
951        // writes — on commit we explicitly re-sync.
952        {
953            let show = show.clone();
954            let text_from_value = text_from_value.clone();
955            let special_text = special_text.clone();
956            let focused = self.focused.clone();
957            let can_up = self.can_step_up.clone();
958            let can_down = self.can_step_down.clone();
959            let min_cap = min;
960            let max_cap = max;
961            let presentation = presentation.clone();
962            ctx.effect(&self.value, move |new_value| {
963                // Update the can-step signals any time the value
964                // changes so the buttons and a11y reflect whether
965                // further stepping is possible under clamp mode.
966                let is_focused = focused.get();
967                if wrap_mode == WrapMode::Wrap {
968                    can_up.set(true);
969                    can_down.set(true);
970                } else {
971                    can_up.set(*new_value < max_cap);
972                    can_down.set(*new_value > min_cap);
973                }
974                if !is_focused {
975                    show(format_for_display(
976                        *new_value,
977                        decimals,
978                        special_text.as_ref(),
979                        text_from_value.as_deref(),
980                        min_cap,
981                        &presentation.get(),
982                    ));
983                }
984            });
985        }
986
987        // Effect: re-format on locale change so special_value_text
988        // and custom formatters re-resolve with the new locale.
989        {
990            let show = show.clone();
991            let text_from_value = text_from_value.clone();
992            let special_text = special_text.clone();
993            let value_signal = self.value.clone();
994            let locale_signal = ctx.locale_signal();
995            let presentation = presentation.clone();
996            // A locale switch re-renders the number in place, in the new
997            // locale's conventions, which from here on are the ones every
998            // other path reads the number with.
999            let localized = self.localized;
1000            let grouping = self.use_grouping;
1001            ctx.effect(&locale_signal, move |_| {
1002                presentation.set(NumberPresentation::resolve(localized, grouping));
1003                show(format_for_display(
1004                    value_signal.get(),
1005                    decimals,
1006                    special_text.as_ref(),
1007                    text_from_value.as_deref(),
1008                    min,
1009                    &presentation.get(),
1010                ));
1011            });
1012        }
1013
1014        // Three commit helpers follow: `set_committed` (the tail —
1015        // clamp, reformat, publish, notify), `commit_text` (the one
1016        // parse site, with the revert on failure) and `commit` (what
1017        // Enter and blur call: reads the field text, hands it over).
1018        // The commit *tail*: clamp into `[min, max]`, reformat the field
1019        // text, publish the value and fire `on_value_changed`. Split out of
1020        // `commit` because the AccessKit `SetValue` path is handed a *number*
1021        // and has nothing to parse — and routing a number back through the
1022        // parser would hand a machine-generated string to a user-supplied
1023        // `value_from_text` that never agreed to read one. The reformat runs
1024        // even when the value is unchanged, so `007` still normalises to `7`.
1025        let set_committed: Rc<dyn Fn(T, &mut EventContext)> = {
1026            let value_signal = self.value.clone();
1027            let show = show.clone();
1028            let text_from_value = text_from_value.clone();
1029            let special_text = special_text.clone();
1030            let on_value_changed = on_value_changed.clone();
1031            let presentation = presentation.clone();
1032            Rc::new(move |candidate: T, ctx: &mut EventContext| {
1033                let old = value_signal.get();
1034                let new_value = candidate.clamp_value(min, max);
1035                show(format_for_display(
1036                    new_value,
1037                    decimals,
1038                    special_text.as_ref(),
1039                    text_from_value.as_deref(),
1040                    min,
1041                    &presentation.get(),
1042                ));
1043                if approx_ne(new_value, old) {
1044                    value_signal.set(new_value);
1045                    if let Some(cb) = on_value_changed.as_ref() {
1046                        cb(new_value, ctx);
1047                    }
1048                }
1049            })
1050        };
1051
1052        // Commit a string. The one parse site: Enter, blur and the AccessKit
1053        // `SetValue` string payload all arrive here, so an assistive-technology
1054        // write honours a custom `value_from_text` and the active locale's
1055        // decimal separator exactly as a typed one does. Reports whether the
1056        // string parsed; on failure the display is restored from the value the
1057        // field still holds — the revert a typed rubbish string gets — and the
1058        // caller decides how loudly to say so.
1059        //
1060        // The parse itself is its own closure because a step reads typed text
1061        // too, and must read it exactly as Enter would.
1062        let parse: Rc<dyn Fn(&str) -> Option<T>> = {
1063            let value_from_text = value_from_text.clone();
1064            let presentation = presentation.clone();
1065            Rc::new(move |raw: &str| {
1066                // A user-supplied parser gets the raw text: it owns the
1067                // whole convention, and de-localizing first would hand it
1068                // a string it never agreed to read.
1069                match value_from_text.as_deref() {
1070                    Some(f) => f(raw.trim()),
1071                    None => presentation.get().parse::<T>(raw),
1072                }
1073            })
1074        };
1075        let commit_text: Rc<dyn Fn(&str, &mut EventContext) -> bool> = {
1076            let value_signal = self.value.clone();
1077            let parse = parse.clone();
1078            let set_committed = set_committed.clone();
1079            Rc::new(move |raw: &str, ctx: &mut EventContext| {
1080                match parse(raw) {
1081                    Some(v) => {
1082                        set_committed(v, ctx);
1083                        true
1084                    }
1085                    None => {
1086                        // Revert: reformat from the value still held.
1087                        set_committed(value_signal.get(), ctx);
1088                        false
1089                    }
1090                }
1091            })
1092        };
1093
1094        let commit: Rc<dyn Fn(&mut EventContext)> = {
1095            let text_signal = self.text_signal.clone();
1096            let field_handle = field_handle.clone();
1097            let commit_text = commit_text.clone();
1098            Rc::new(move |ctx: &mut EventContext| {
1099                // The field, not the signal it mirrors typing into a frame
1100                // late: Enter delivered in the same batch as the digit before
1101                // it would otherwise commit the text without that digit. The
1102                // signal is the fallback for a field that cannot answer.
1103                let raw = field_handle
1104                    .text_as_typed()
1105                    .unwrap_or_else(|| text_signal.get());
1106                let _ = commit_text(&raw, ctx);
1107            })
1108        };
1109
1110        // ── Step helpers ───────────────────────────────────────────
1111        //
1112        // Two closures cover the two firing pathways:
1113        //
1114        // - `step` is called from event handlers (keyboard, wheel,
1115        //   button tap, a11y action) and takes an `EventContext`
1116        //   so it can fire the user's `on_value_changed` callback
1117        //   and request a frame.
1118        //
1119        // - `step_silent` is called from signal-only contexts
1120        //   (hold-to-repeat on the step buttons, which lives in a
1121        //   frame-tick effect that has no `EventContext` to hand).
1122        //   It mutates `value` and `text_signal` and lets the
1123        //   bindings on those signals trigger the redraw. The
1124        //   user's `on_value_changed` callback is deliberately
1125        //   skipped — signal observers still see every change,
1126        //   which is the primary notification channel.
1127        //
1128        // `can_step_up` / `can_step_down` are kept in sync by the
1129        // value-effect above.
1130
1131        fn apply_step<T: SpinValue>(
1132            dir: i32,
1133            page: bool,
1134            step_type: StepType,
1135            wrap_mode: WrapMode,
1136            single_step: T,
1137            page_step: T,
1138            min: T,
1139            max: T,
1140            current: T,
1141        ) -> T {
1142            let base_step = if page { page_step } else { single_step };
1143            let effective = resolve_effective_step(step_type, current, base_step);
1144            let stepped = if dir > 0 {
1145                current.saturating_add(effective)
1146            } else {
1147                current.saturating_sub(effective)
1148            };
1149            if stepped < min || stepped > max {
1150                match wrap_mode {
1151                    WrapMode::Clamp => stepped.clamp_value(min, max),
1152                    WrapMode::Wrap => {
1153                        if stepped > max {
1154                            min
1155                        } else {
1156                            max
1157                        }
1158                    }
1159                }
1160            } else {
1161                stepped
1162            }
1163        }
1164
1165        // Signal-only step: publishes the new value and its text, and says
1166        // what it did so the caller can fire `on_value_changed` after the
1167        // fact.
1168        //
1169        // A step starts from what the field shows, which is not always the
1170        // value: somebody who types 35 and presses Up has just heard the field
1171        // say 35, and stepping from the value committed before the typing
1172        // threw the typing away and moved from a number nobody could hear.
1173        // This is `QAbstractSpinBox::stepBy`, which interprets the pending text
1174        // before it steps. So typed text (anything the field holds that
1175        // `show` did not put there) is read first, exactly as Enter reads it:
1176        // parsed, clamped, and stepped from. The typed number and the step are
1177        // one change, with one signal write and one notification.
1178        //
1179        // Text that does not parse steps nothing: the value goes back into the
1180        // field and the press ends there, so what the user hears is the number
1181        // the next press will step from, not one two moves removed from
1182        // anything they were told. Qt does the same, for the same reason.
1183        //
1184        // The auto-repeat of a held step button calls this directly. Its first
1185        // step went through `step` below, which has already read the typing,
1186        // so every repeat finds the field showing what `show` wrote.
1187        let step_silent: Rc<dyn Fn(i32, bool) -> StepOutcome<T>> = {
1188            let value_signal = self.value.clone();
1189            let field_handle = field_handle.clone();
1190            let last_shown = last_shown.clone();
1191            let parse = parse.clone();
1192            let show = show.clone();
1193            let text_from_value = text_from_value.clone();
1194            let special_text = special_text.clone();
1195            let presentation = presentation.clone();
1196            Rc::new(move |dir: i32, page: bool| {
1197                if read_only {
1198                    return StepOutcome::Unchanged;
1199                }
1200                let display = |value: T| {
1201                    format_for_display(
1202                        value,
1203                        decimals,
1204                        special_text.as_ref(),
1205                        text_from_value.as_deref(),
1206                        min,
1207                        &presentation.get(),
1208                    )
1209                };
1210                let current = value_signal.get();
1211                let typed = field_handle
1212                    .text_as_typed()
1213                    .filter(|text| *text != *last_shown.borrow());
1214                let from = match typed.as_deref().map(|text| parse(text)) {
1215                    None => current,
1216                    Some(Some(read)) => read.clamp_value(min, max),
1217                    Some(None) => {
1218                        show(display(current));
1219                        return StepOutcome::Restored;
1220                    }
1221                };
1222                let new_value = apply_step(
1223                    dir,
1224                    page,
1225                    step_type,
1226                    wrap_mode,
1227                    single_step,
1228                    page_step,
1229                    min,
1230                    max,
1231                    from,
1232                );
1233                if approx_eq(new_value, current) {
1234                    // Typing that came to the value already held still leaves
1235                    // the field showing the typing; put the value's own text
1236                    // back.
1237                    if typed.is_some() {
1238                        show(display(new_value));
1239                        return StepOutcome::Restored;
1240                    }
1241                    return StepOutcome::Unchanged;
1242                }
1243                value_signal.set(new_value);
1244                show(display(new_value));
1245                StepOutcome::Changed(new_value)
1246            })
1247        };
1248
1249        // Contextful step: wraps `step_silent` and fires the user
1250        // callback + frame request on change.
1251        let step: Rc<dyn Fn(i32, bool, &mut EventContext)> = {
1252            let step_silent = step_silent.clone();
1253            let on_value_changed = on_value_changed.clone();
1254            Rc::new(move |dir: i32, page: bool, ctx: &mut EventContext| {
1255                match step_silent(dir, page) {
1256                    StepOutcome::Changed(new_value) => {
1257                        if let Some(cb) = on_value_changed.as_ref() {
1258                            cb(new_value, ctx);
1259                        }
1260                        ctx.request_frame();
1261                    }
1262                    StepOutcome::Restored => ctx.request_frame(),
1263                    StepOutcome::Unchanged => {}
1264                }
1265            })
1266        };
1267
1268        // ── Inner editing field ────────────────────────────────────
1269        //
1270        // Uses `TextInputField` directly rather than the `TextInput`
1271        // composite — the composite's border / padding / placeholder
1272        // overlay is reproduced here around both the field and the
1273        // buttons in one shared frame, instead of framing the text
1274        // by itself.
1275        let inner_height =
1276            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
1277        let text_area_height =
1278            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
1279
1280        let mut field = TextInputField::new(self.text_signal.clone())
1281            .share_handle(&field_handle)
1282            .enabled(enabled)
1283            // An assistive-technology `SetValue` on the inner text node is a
1284            // finished edit, not a keystroke, so it takes the same parse,
1285            // clamp and revert `Enter` takes. Handed the string rather than
1286            // reading `text_signal`, which this edit has not synced yet.
1287            .on_access_set_value({
1288                let commit_text = commit_text.clone();
1289                move |text: &str, ctx: &mut EventContext| commit_text(text, ctx)
1290            })
1291            .read_only(read_only)
1292            .placeholder(self.placeholder.clone())
1293            .text_height(text_area_height);
1294        // The per-character filter speaks for the default parser and for it
1295        // alone: it admits exactly what `NumberPresentation::parse` can read. A
1296        // custom `value_from_text` owns the whole text convention (a month
1297        // typed as "march", a duration as "1h30"), and filtering for digits in
1298        // front of it dropped the very characters it was installed to read,
1299        // before it ever saw them. So with one installed every character
1300        // reaches the field, and the parser is the only judge, at commit, with
1301        // the usual revert for what it refuses.
1302        if value_from_text.is_none() {
1303            field = field.char_filter({
1304                let presentation = presentation.clone();
1305                move |c| presentation.get().accepts_char::<T>(c)
1306            });
1307        }
1308        // Suffix wiring:
1309        //   • plain static suffix              → `.suffix(..)` (no signal)
1310        //   • static suffix + special_value    → reactive: hide the suffix
1311        //     when `value == min`, where the field shows the special text
1312        //     whether or not it has focus, so `"Auto"` reads cleanly without
1313        //     a trailing unit. A number typed over it takes its unit when it
1314        //     is committed.
1315        //   • no suffix and no special         → nothing to do.
1316        //
1317        // The reactive case uses a mutable intermediate signal
1318        // rather than feeding `TextInputField::suffix` a
1319        // derived signal directly — `ctx.effect` requires a
1320        // mutable source, and the field needs to drive a
1321        // relayout + re-measure when the suffix flips on/off.
1322        // Driven by the value, never by the text: the field writes its text
1323        // out while it holds its own state, and a suffix change re-enters
1324        // that state to measure the new suffix.
1325        if !suffix_str.is_empty() {
1326            if self.special_value_text.is_some() {
1327                let unit_for = {
1328                    let unit = suffix_str.clone();
1329                    move |v: T| {
1330                        if approx_eq(v, min) {
1331                            String::new()
1332                        } else {
1333                            unit.clone()
1334                        }
1335                    }
1336                };
1337                // Seeded here: an effect fires on a change, never as it is
1338                // registered, and the field reads its first suffix at build.
1339                let suffix_live = ctx.signal(unit_for(self.value.get()));
1340                let suffix_for_value = suffix_live.clone();
1341                ctx.effect(&self.value, move |v| {
1342                    let next = unit_for(*v);
1343                    if suffix_for_value.get() != next {
1344                        suffix_for_value.set(next);
1345                    }
1346                });
1347                field = field.suffix(suffix_live);
1348            } else {
1349                field = field.suffix(suffix_str.clone());
1350            }
1351        }
1352        // Submit on Enter: commit in-place, keep focus.
1353        {
1354            let commit = commit.clone();
1355            field = field.on_submit_fn(move |ctx| commit(ctx));
1356        }
1357        // Commit on blur: after the field clears its selection and
1358        // resets scroll.
1359        {
1360            let commit = commit.clone();
1361            field = field.on_blur_fn(move |ctx| commit(ctx));
1362        }
1363
1364        // Focus changes nothing in the text. The field used to swap a
1365        // `special_value_text` for the plain number as focus arrived, in the
1366        // update that moved focus, so a screen reader landing on "Auto" was
1367        // told "0" and never what 0 means here; a step back to `min` and a
1368        // commit there both put the special text back into the focused field
1369        // anyway. Qt's `QSpinBox` keeps `specialValueText` through focus (Qt
1370        // 5.15, measured: a Tab focus leaves "Auto" in the field, selected),
1371        // and so does this: keyboard focus selects the whole text, so a number
1372        // typed replaces it, and a step moves off it.
1373
1374        // The field is the spin button. It holds focus, the caret and the
1375        // text, so it is the node a focus change reports and the one a
1376        // screen reader names. The ARIA spinbutton pattern puts the role on
1377        // the focusable element, which is usually the one taking the text, and
1378        // AccessKit's consumer counts `Role::SpinButton` as a text input
1379        // (`NodeRef::is_text_input`), so this one node carries the text runs
1380        // and gets AT-SPI's Text, EditableText and Value interfaces, UIA's
1381        // Text, Value and RangeValue patterns, and on macOS an
1382        // `AXIncrementor` with the text attributes. With the name on an outer
1383        // node instead, a focus change reported a nameless text field; with
1384        // it on both, two nodes said it. And Orca speaks a value change only
1385        // on the object with focus, so the number moves where the focus is,
1386        // or an arrow press is silent.
1387        //
1388        // The composite's own node is structure (see `accessibility` and
1389        // `accessibility_proxy` below), and whatever an application attaches
1390        // to the spin box's id lands here, after this.
1391        //
1392        // Its value stays the field's own, the text as shown and typed, which
1393        // is what its text runs review. The suffix is painted beside that text
1394        // and is in neither: appending it here made the node announce "100 %"
1395        // and review "100", the divergence `audit::text_range_divergences`
1396        // exists to catch. The unit reaches a reader once the field emits it
1397        // as text; `engineering/docs/accessibility-internal-audit.md` records the gap.
1398        let field = field.access_customize({
1399            let label = self.label.clone();
1400            let value = self.value.clone();
1401            move |builder: &mut AccessNodeBuilder| {
1402                use teksilo_core::accesskit::{Action, Role};
1403                builder.set_role(Role::SpinButton);
1404                if let Some(label) = &label {
1405                    builder.set_name(label.resolve_now());
1406                }
1407                builder.set_numeric_value(value.get().to_f64());
1408                builder.set_min_numeric_value(min.to_f64());
1409                builder.set_max_numeric_value(max.to_f64());
1410                builder.set_numeric_value_step(single_step.to_f64());
1411                builder.set_numeric_value_jump(page_step.to_f64());
1412                // The field advertises `SetValue` and `Focus` itself; stepping
1413                // is the spin box's, serviced by its handler when the action
1414                // bubbles up from here. A read-only spin box offers neither:
1415                // macOS gates settability on the advertisement alone.
1416                if !read_only {
1417                    builder.add_action(Action::Increment);
1418                    builder.add_action(Action::Decrement);
1419                }
1420            }
1421        });
1422        let field_id = ctx.add(field);
1423        self.field_id = Some(field_id);
1424
1425        // Wrap field in vertical padding so it aligns inside the frame.
1426        // (Horizontal Expand is owned by the active SpinBoxStyle so a
1427        // custom recipe can re-arrange the row.)
1428        let padded_field_id = ctx.add(
1429            Padding::new(
1430                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1431                0.0,
1432                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1433                0.0,
1434            )
1435            .child(field_id),
1436        );
1437
1438        // ── Step buttons ───────────────────────────────────────────
1439        let (step_up_id, step_down_id) = if self.button_layout != ButtonLayout::Hidden {
1440            let (u, d) = build_step_buttons(
1441                ctx,
1442                &step,
1443                &step_silent,
1444                self.can_step_up.clone(),
1445                self.can_step_down.clone(),
1446                enabled && !read_only,
1447                field_dims::TEXT_FIELD_HEIGHT,
1448                field_dims::TEXT_FIELD_CORNER_RADIUS,
1449            );
1450            (Some(u), Some(d))
1451        } else {
1452            (None, None)
1453        };
1454
1455        // ── Delegate visual chrome (row layout + divider + bordered
1456        // surface) to the active SpinBoxStyle.
1457        let style =
1458            crate::styles::recipe_spin_box_style::resolve_spin_box_style(&self.style_override, ctx);
1459        // Derive the disabled state from the arena rather than from the
1460        // build-time `enabled` snapshot above, so a bound `Signal<bool>`
1461        // (or a disabled *ancestor*) re-tints the chrome with no rebuild.
1462        let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
1463        let cfg = teksilo_core::styles::SpinBoxStyleConfig {
1464            field: padded_field_id,
1465            step_up: step_up_id,
1466            step_down: step_down_id,
1467            layout: self.button_layout,
1468            is_focused: self.focused.clone(),
1469            is_disabled,
1470        };
1471        let zstack_id = style.make_body(&cfg, ctx);
1472        let _ = focus_ring_width;
1473        let _ = field_border_width;
1474
1475        // Resolve the width policy into a concrete pixel cap (or
1476        // `None` for `Fill`). Char-mode measurement uses the app-
1477        // wide `SharedTypesetter` — same backend the field paints
1478        // with — so the result tracks runtime theme switches and
1479        // HiDPI scale changes. `'0'` is the reference digit since
1480        // Inter and most UI sans-serifs ship tabular-figure
1481        // numerals; the suffix is measured separately because it
1482        // may have different glyph advances (e.g. `" %"`).
1483        let min_width = match self.button_layout {
1484            ButtonLayout::Stacked => min_width_with_buttons(&theme.input),
1485            ButtonLayout::Hidden => min_width_no_buttons(&theme.input),
1486        };
1487        let pixel_cap: Option<f32> = match self.width_policy {
1488            WidthPolicy::Fill => None,
1489            WidthPolicy::Pixels(px) => Some(px.max(min_width)),
1490            WidthPolicy::Chars(chars) => {
1491                let style = &theme.typography.body;
1492                let sample: String = "0".repeat(chars as usize);
1493                let digits_w = measure_width_px(ctx, &sample, style);
1494                let suffix_w = if suffix_str.is_empty() {
1495                    0.0
1496                } else {
1497                    measure_width_px(ctx, &suffix_str, style)
1498                };
1499                let button_chrome = match self.button_layout {
1500                    // step button + 4 dp divider padding + 1 dp divider
1501                    ButtonLayout::Stacked => SPIN_BOX_STEP_BUTTON_WIDTH + 4.0 + 1.0,
1502                    ButtonLayout::Hidden => 0.0,
1503                };
1504                // 2 dp slack so the caret and a trailing zero never
1505                // paint flush against the right edge.
1506                let chrome = field_dims::TEXT_FIELD_PADDING_HORIZONTAL * 2.0 + button_chrome + 2.0;
1507                Some((digits_w + suffix_w + chrome).max(min_width))
1508            }
1509        };
1510
1511        // Size envelope:
1512        //   MinSize  → enforce a floor so the field and buttons
1513        //              still fit even when a narrow parent would
1514        //              otherwise squash the widget.
1515        //   The horizontal cap (when `pixel_cap` is `Some`) is
1516        //   applied by `SpinBox::size_that_fits` narrowing the
1517        //   proposal, NOT by wrapping in `MaxSize`. `MaxSize`
1518        //   clips its children, which would truncate the outer
1519        //   half of the focus-state border stroke against the
1520        //   widget's own shape quad (visible as a ring clipped on
1521        //   all four sides).
1522        let sized_id = ctx.add(
1523            MinSize::new(
1524                min_width,
1525                crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
1526            )
1527            .child(zstack_id),
1528        );
1529        // Stash the resolved cap + floor on `self` for
1530        // `size_that_fits` to read at layout time.
1531        self.pixel_cap = pixel_cap;
1532        self.min_width = min_width;
1533
1534        // ── Root: attach key + wheel handlers on the outer sized id ─
1535        //
1536        // Preview-pass `on_key_preview` claims Up / Down / PageUp /
1537        // PageDown before the focused `TextInputField` ever sees them;
1538        // the handler below says why preview rather than bubble.
1539        let root_id = sized_id;
1540        self.root_child_id = Some(root_id);
1541
1542        let step_for_key = step.clone();
1543        let step_for_wheel = step.clone();
1544        let set_committed_for_a11y = set_committed.clone();
1545        let commit_text_for_a11y = commit_text.clone();
1546
1547        let handlers = HandlerSet::new()
1548            // The SpinBox is not itself focusable — focus lands inside the
1549            // inner TextInputField. `focus_within` writes `true` whenever
1550            // any descendant (in practice, the field) holds focus, driving
1551            // the unified outer focus ring + the suffix / text-formatting
1552            // effects that previously read an `interaction_signal` piped
1553            // out of the field.
1554            .focus_within(self.focused.clone())
1555            // Preview-pass dispatch — claims ArrowUp/ArrowDown/PageUp/PageDown
1556            // for stepping BEFORE the focused TextInputField sees them. The
1557            // bubble-pass `on_key` previously relied on the field happening
1558            // not to bind arrow keys; preview makes the contract explicit so
1559            // future field changes (multiline caret motion, etc.) cannot
1560            // silently break stepping. Non-arrow keys return `Ignored` and
1561            // fall through to the field for normal text input.
1562            .on_key_preview(move |event, ctx| {
1563                // `enabled` needs no test: the dispatcher gates on
1564                // `arena.is_enabled` before any handler runs.
1565                if read_only {
1566                    return EventResponse::Ignored;
1567                }
1568                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1569                    return EventResponse::Ignored;
1570                };
1571                // `TextEditable` is what leaves `Home` / `End` to the caret,
1572                // and `Vertical` is what leaves it the horizontal arrows. Both
1573                // are decisions rather than omissions, and both are asserted in
1574                // `common::range_nav`'s own tests. `rtl` is never consulted:
1575                // no horizontal arrow is claimed.
1576                match range_nav::range_move(
1577                    *key,
1578                    *modifiers,
1579                    RangeKind::TextEditable,
1580                    RangeAxis::Vertical,
1581                    false,
1582                ) {
1583                    Some(RangeMove::Step { increase }) => {
1584                        (step_for_key)(if increase { 1 } else { -1 }, false, ctx);
1585                        EventResponse::Handled
1586                    }
1587                    Some(RangeMove::Page { increase }) => {
1588                        (step_for_key)(if increase { 1 } else { -1 }, true, ctx);
1589                        EventResponse::Handled
1590                    }
1591                    // A `TextEditable` never reaches an extremum — swept in
1592                    // `range_nav`'s tests — so this arm states the contract
1593                    // rather than guarding a case that can occur.
1594                    Some(RangeMove::ToMin | RangeMove::ToMax) | None => EventResponse::Ignored,
1595                }
1596            })
1597            .on_scroll({
1598                let focused = self.focused.clone();
1599                move |event, ctx| {
1600                    // `enabled` is deliberately absent, for the reason the key
1601                    // handler above states: the dispatcher gates on
1602                    // `arena.is_enabled` before any handler runs, and the local
1603                    // is a *build-time* snapshot — a `Signal`-bound spin box
1604                    // enabled after it was built would keep a dead wheel.
1605                    if read_only || wheel_mode == WheelMode::Disabled {
1606                        return EventResponse::Ignored;
1607                    }
1608                    // `Focused` wheel mode only fires when the
1609                    // inner field currently holds focus. `Hover` is
1610                    // the natural fallthrough — scroll events reach
1611                    // the widget only when the pointer is over it.
1612                    if wheel_mode == WheelMode::Focused && !focused.get() {
1613                        return EventResponse::Ignored;
1614                    }
1615                    let WidgetEvent::Scroll { delta, .. } = event else {
1616                        return EventResponse::Ignored;
1617                    };
1618                    let y = match delta {
1619                        ScrollDelta::Lines { y, .. } => *y,
1620                        ScrollDelta::Pixels { y, .. } => *y,
1621                    };
1622                    if y == 0.0 {
1623                        return EventResponse::Ignored;
1624                    }
1625                    // Teksilo's `ScrollDelta` is a *scroll offset* delta, not
1626                    // a raw wheel reading: `translate_mouse_wheel` negates
1627                    // winit's natural sign so that **positive y scrolls
1628                    // down** (the offset grows, content moves up) — which is
1629                    // what `ScrollArea` and every data view add straight to
1630                    // their scroll position. So a wheel-down notch arrives
1631                    // as `y > 0` and must *decrement*, matching every other
1632                    // stepper on the platform.
1633                    let dir = if y > 0.0 { -1 } else { 1 };
1634                    (step_for_wheel)(dir, false, ctx);
1635                    EventResponse::Handled
1636                }
1637            })
1638            // Full-payload AccessKit handler. `SetValue` carries its value in
1639            // `ActionData`, which `on_access_action` cannot see, so Increment
1640            // and Decrement sit beside it here: the whole action set reads as
1641            // one match rather than being split across two slots. Both payload
1642            // shapes are serviced, because both are sent in the field:
1643            //   * `NumericValue(f64)` — macOS `setAccessibilityValue:` with an
1644            //     `NSNumber` (a `Role::SpinButton` publishes a numeric
1645            //     `AXValue`), and AT-SPI's `Value.SetCurrentValue`, which is how
1646            //     Orca sets a spin button. AT-SPI publishes the `Value`
1647            //     interface off `numeric_value` alone, so that door is open
1648            //     whether or not the action is advertised.
1649            //   * `Value(Box<str>)` — macOS with an `NSString`, and Teksilo's
1650            //     own automation `set_value` tool, which sends only this shape.
1651            // These arrive from the field, which is the spin button node and
1652            // leaves stepping to its host: an action unhandled there bubbles up
1653            // to here. `Action::Focus` has no arm: the dispatcher services it
1654            // before any widget sees it, on the field, which is focusable.
1655            .on_access_action_request(move |action, _target_node, data, ctx| {
1656                use teksilo_core::accesskit::{Action, ActionData};
1657                // A read-only spin box refuses every mutating action rather
1658                // than reporting a success that never happened — the contract
1659                // an automation client relies on to learn its call did nothing.
1660                if read_only {
1661                    return EventResponse::Ignored;
1662                }
1663                match (action, data) {
1664                    (Action::Increment, _) => {
1665                        (step)(1, false, ctx);
1666                        EventResponse::Handled
1667                    }
1668                    (Action::Decrement, _) => {
1669                        (step)(-1, false, ctx);
1670                        EventResponse::Handled
1671                    }
1672                    // A number goes straight to the commit tail: clamp,
1673                    // reformat, publish, notify.
1674                    (Action::SetValue, Some(ActionData::NumericValue(v))) => {
1675                        // `from_f64_saturating` maps NaN to zero; writing 0 for
1676                        // a malformed request is worse than declining it.
1677                        if !v.is_finite() {
1678                            return EventResponse::Ignored;
1679                        }
1680                        (set_committed_for_a11y)(T::from_f64_saturating(v), ctx);
1681                        ctx.request_frame();
1682                        EventResponse::Handled
1683                    }
1684                    // A string takes the same parse Enter takes, so a custom
1685                    // `value_from_text` and the locale's decimal separator are
1686                    // honoured. An unparseable string reverts the display and
1687                    // is reported unhandled, so the caller hears about it
1688                    // instead of watching a silent no-op.
1689                    (Action::SetValue, Some(ActionData::Value(v))) => {
1690                        let parsed = (commit_text_for_a11y)(v.as_ref(), ctx);
1691                        ctx.request_frame();
1692                        if parsed {
1693                            EventResponse::Handled
1694                        } else {
1695                            EventResponse::Ignored
1696                        }
1697                    }
1698                    _ => EventResponse::Ignored,
1699                }
1700            });
1701        // Bind `value` to the field, which publishes the spin button's numeric
1702        // value, so that node refreshes on every change even when the text
1703        // does not (a formatter can show two values alike).
1704        self.value.bind_to(
1705            field_id,
1706            ctx.binding_registry(),
1707            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1708        );
1709
1710        ctx.apply_self_handlers(handlers);
1711
1712        // ── Tooltip attachment ─────────────────────────────────────
1713        if let Some(content) = self.composite_tooltip_content.take() {
1714            let delay = ctx.theme().motion.tooltip_delay_heavy;
1715            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
1716        } else if let Some(source) = self.rich_tooltip_source.clone() {
1717            let delay = ctx.theme().motion.tooltip_delay;
1718            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
1719        } else if let Some(text) = self.tooltip_text.clone() {
1720            let delay = ctx.theme().motion.tooltip_delay;
1721            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
1722        }
1723
1724        vec![root_id]
1725    }
1726
1727    fn layout_response(
1728        &self,
1729        proposal: SizeProposal,
1730        ctx: &LayoutContext,
1731    ) -> teksilo_core::widget::LayoutResponse {
1732        // Narrow the parent's proposal by `pixel_cap` (if any)
1733        // before delegating. This enforces the `.width(...)` /
1734        // `.width_chars(...)` caps without wrapping the subtree
1735        // in a clipping `MaxSize` — the focus-state border stroke
1736        // can then extend 1 dp outside the visual bounds
1737        // (Int UI focus thickening) without being chopped off
1738        // against the shape quad.
1739        // `pixel_cap` / `min_width` are measured at 1.0 scale in `build()`. The
1740        // inner field grows its text by `ctx.text_scale`, so the cap must grow
1741        // too or the scaled digits clip against an un-grown width cap.
1742        let scale = ctx.text_scale;
1743        let pixel_cap = self.pixel_cap.map(|c| c * scale);
1744        let min_width = self.min_width * scale;
1745        let effective_proposal = SizeProposal {
1746            width: match (proposal.width, pixel_cap) {
1747                (Some(w), Some(cap)) => Some(w.min(cap).max(min_width)),
1748                (None, Some(cap)) => Some(cap.max(min_width)),
1749                (w, None) => w,
1750            },
1751            height: proposal.height,
1752        };
1753        let child_size = self
1754            .root_child_id
1755            .and_then(|id| ctx.child_size(id, effective_proposal))
1756            .unwrap_or_else(|| effective_proposal.resolve(0.0, 0.0));
1757        // Claim the (cap-narrowed) proposal width on the cross axis — the
1758        // inner `ZStack` queries its children with `SizeProposal::unspecified`,
1759        // so the offered width never reaches the inner `HStack` during
1760        // measurement; without this clamp the chain returns just
1761        // `MinSize`'s floor and the SpinBox collapses regardless of
1762        // `WidthPolicy::Fill` or `Pixels`/`Chars` caps.
1763        let w = match effective_proposal.width {
1764            Some(pw) => pw.max(child_size.width),
1765            None => child_size.width,
1766        };
1767        Size::new(w, child_size.height).into()
1768    }
1769
1770    fn place_children(
1771        &self,
1772        bounds: Rect,
1773        _proposal: SizeProposal,
1774        children: &mut [WidgetPlacement],
1775        _ctx: &LayoutContext,
1776    ) {
1777        if let Some(p) = children.first_mut() {
1778            p.origin = Point::new(bounds.x, bounds.y);
1779            p.size = bounds.size();
1780        }
1781    }
1782
1783    fn children(&self) -> Vec<WidgetId> {
1784        self.root_child_id.into_iter().collect()
1785    }
1786
1787    /// Structure. The spin button is the editing field, which holds focus
1788    /// and says the name, the value and the range (see `build`); a second
1789    /// node saying them around it is what made a screen reader either name
1790    /// the field twice or not at all. `GenericContainer` is what the
1791    /// presentational pass collapses.
1792    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1793        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1794    }
1795
1796    /// The editing field stands for the spin box, so what an application
1797    /// attaches to the spin box's id (`access_label`, a `FormLayout`'s
1798    /// `labelled_by`, `access_described_by`, the tooltip) lands on the node
1799    /// that holds focus.
1800    fn accessibility_proxy(&self) -> Option<WidgetId> {
1801        self.field_id
1802    }
1803}
1804
1805// ── Helpers ────────────────────────────────────────────────────────
1806
1807/// Build the stacked up/down step buttons. Returns the two
1808/// `WidgetId`s (up, down) for the caller to pass to the active
1809/// `SpinBoxStyle` through `SpinBoxStyleConfig`, which owns the row.
1810///
1811/// `step` (with `EventContext`) is called on the initial tap;
1812/// `step_silent` is called from the hold-to-repeat timer, which
1813/// runs inside a frame-tick effect with no `EventContext` to hand.
1814fn build_step_buttons<T: SpinValue>(
1815    ctx: &mut BuildContext,
1816    step: &Rc<dyn Fn(i32, bool, &mut EventContext)>,
1817    step_silent: &Rc<dyn Fn(i32, bool) -> StepOutcome<T>>,
1818    can_up: Signal<bool>,
1819    can_down: Signal<bool>,
1820    enabled: bool,
1821    frame_height: f32,
1822    corner_radius: f32,
1823) -> (WidgetId, WidgetId) {
1824    // Each button is half of the field's inner height (minus the
1825    // borders and a 1 px gutter between the two).
1826    let button_height = ((frame_height - 2.0) * 0.5).max(8.0);
1827    let button_width = SPIN_BOX_STEP_BUTTON_WIDTH;
1828
1829    let up_icon = chevron_up_icon(8.0);
1830    let down_icon = chevron_down_icon(8.0);
1831
1832    // Derived enabled signals: OR with the caller-wide `enabled`.
1833    let up_enabled = if enabled { can_up } else { Signal::new(false) };
1834    let down_enabled = if enabled {
1835        can_down
1836    } else {
1837        Signal::new(false)
1838    };
1839
1840    let step_for_up_tap = step.clone();
1841    let silent_for_up_auto = step_silent.clone();
1842    let up_button = StepButton::new(up_icon, up_enabled, move |ctx| {
1843        (step_for_up_tap)(1, false, ctx);
1844    })
1845    .on_auto_repeat(move || {
1846        (silent_for_up_auto)(1, false);
1847    })
1848    .size(button_width, button_height)
1849    .corner_radius(CornerRadius {
1850        top_left: 0.0,
1851        top_right: corner_radius,
1852        bottom_left: 0.0,
1853        bottom_right: 0.0,
1854    });
1855
1856    let step_for_down_tap = step.clone();
1857    let silent_for_down_auto = step_silent.clone();
1858    let down_button = StepButton::new(down_icon, down_enabled, move |ctx| {
1859        (step_for_down_tap)(-1, false, ctx);
1860    })
1861    .on_auto_repeat(move || {
1862        (silent_for_down_auto)(-1, false);
1863    })
1864    .size(button_width, button_height)
1865    .corner_radius(CornerRadius {
1866        top_left: 0.0,
1867        top_right: 0.0,
1868        bottom_left: 0.0,
1869        bottom_right: corner_radius,
1870    });
1871
1872    (ctx.add(up_button), ctx.add(down_button))
1873}
1874
1875/// Small chevron-up icon at `size` px. Mirrors the shape of the
1876/// `chevron_down` icon provided by `IconWidget`.
1877fn chevron_up_icon(size: f32) -> IconWidget {
1878    let mut path = Path::new();
1879    let s = size;
1880    path.move_to(Point::new(s * 0.25, s * 0.65));
1881    path.line_to(Point::new(s * 0.5, s * 0.35));
1882    path.line_to(Point::new(s * 0.75, s * 0.65));
1883    IconWidget::from_path(path, size)
1884}
1885
1886fn chevron_down_icon(size: f32) -> IconWidget {
1887    let mut path = Path::new();
1888    let s = size;
1889    path.move_to(Point::new(s * 0.25, s * 0.35));
1890    path.line_to(Point::new(s * 0.5, s * 0.65));
1891    path.line_to(Point::new(s * 0.75, s * 0.35));
1892    IconWidget::from_path(path, size)
1893}
1894
1895/// How the number itself is rendered and read back: the locale's
1896/// conventions, or the C locale.
1897///
1898/// Held in one [`LivePresentation`] per spin box and read from there by
1899/// the format and parse paths together, so the two can never disagree
1900/// about which separator this field is using: the single-source
1901/// discipline `DateEdit` gets from its one `ParsedPattern`.
1902#[derive(Clone)]
1903pub(crate) struct NumberPresentation {
1904    symbols: Option<Rc<teksilo_i18n::NumberSymbols>>,
1905    grouping: bool,
1906}
1907
1908impl NumberPresentation {
1909    /// Resolve against the active locale. `localized == false` yields a
1910    /// presentation that is the identity in both directions.
1911    pub(crate) fn resolve(localized: bool, grouping: bool) -> Self {
1912        Self {
1913            symbols: localized.then(teksilo_i18n::NumberSymbols::current),
1914            grouping,
1915        }
1916    }
1917
1918    /// C-locale digits in, display string out.
1919    fn render(&self, plain: String) -> String {
1920        match &self.symbols {
1921            Some(sym) => sym.localize(&plain, self.grouping),
1922            None => plain,
1923        }
1924    }
1925
1926    /// Display string in, C-locale digits out. `None` when the text
1927    /// cannot be a number in this locale.
1928    fn read(&self, raw: &str) -> Option<String> {
1929        match &self.symbols {
1930            Some(sym) => sym.delocalize(raw),
1931            None => Some(raw.trim().to_string()),
1932        }
1933    }
1934
1935    /// Parse user input into a value, going through the locale first.
1936    fn parse<T: SpinValue>(&self, raw: &str) -> Option<T> {
1937        T::parse(&self.read(raw)?)
1938    }
1939
1940    /// Per-character input filter. Widens the type's own filter with the
1941    /// characters this locale writes numbers with, so a French user can
1942    /// type `,` and an Egyptian user can type `٫` or Arabic-Indic
1943    /// digits — while the ASCII forms keep working everywhere, because
1944    /// people type on the keyboard they have.
1945    fn accepts_char<T: SpinValue>(&self, c: char) -> bool {
1946        if T::is_valid_input_char(c) {
1947            return true;
1948        }
1949        let Some(sym) = &self.symbols else {
1950            return false;
1951        };
1952        if sym.has_non_ascii_digits() && sym.delocalize(&c.to_string()).is_some() {
1953            return true;
1954        }
1955        // The group separator is only typeable when this field shows
1956        // groups; otherwise it is noise the user cannot have meant.
1957        [
1958            Some(sym.decimal_separator()),
1959            Some(sym.minus_sign()),
1960            Some(sym.plus_sign()),
1961            self.grouping.then(|| sym.group_separator()),
1962        ]
1963        .into_iter()
1964        .flatten()
1965        .any(|sep| sep.chars().any(|sc| sc == c))
1966    }
1967}
1968
1969/// The [`NumberPresentation`] one spin box renders and reads its number
1970/// with, shared by every path that does either and replaced in place when
1971/// the locale changes. See `build` for why a copy per path does not do.
1972#[derive(Clone)]
1973struct LivePresentation(Rc<RefCell<NumberPresentation>>);
1974
1975impl LivePresentation {
1976    fn new(presentation: NumberPresentation) -> Self {
1977        Self(Rc::new(RefCell::new(presentation)))
1978    }
1979
1980    /// The presentation in force now. A clone, two words, so no borrow is
1981    /// held across the call that uses it.
1982    fn get(&self) -> NumberPresentation {
1983        self.0.borrow().clone()
1984    }
1985
1986    fn set(&self, presentation: NumberPresentation) {
1987        self.0.replace(presentation);
1988    }
1989}
1990
1991/// Format `value` for display, honoring `special_value_text` when
1992/// applicable and deferring to a user-supplied formatter when set.
1993///
1994/// A user-supplied `custom` formatter owns the whole string and is
1995/// **not** localized afterwards: it already returns exactly what the
1996/// caller wants shown, and re-punctuating it would corrupt formats the
1997/// caller composed deliberately.
1998fn format_for_display<T: SpinValue>(
1999    value: T,
2000    decimals: u8,
2001    special: Option<&LocalizedString>,
2002    custom: Option<&dyn Fn(T) -> LocalizedString>,
2003    min: T,
2004    presentation: &NumberPresentation,
2005) -> String {
2006    if let Some(special_text) = special
2007        && approx_eq(value, min)
2008    {
2009        return special_text.resolve_now();
2010    }
2011    match custom {
2012        Some(f) => f(value).resolve_now(),
2013        None => presentation.render(value.format(decimals)),
2014    }
2015}
2016
2017/// Decide the effective step for an [`Adaptive`](StepType::Adaptive)
2018/// step type given the current value. For a value ∈ [10^n,
2019/// 10^(n+1)) the effective step is 10^n from 10 upwards; below 10
2020/// the step stays at `base_step`, which also keeps it from
2021/// vanishing inside [0, 1).
2022fn resolve_effective_step<T: SpinValue>(step_type: StepType, current: T, base_step: T) -> T {
2023    if step_type == StepType::Fixed {
2024        return base_step;
2025    }
2026    let abs = current.to_f64().abs();
2027    if abs < 10.0 {
2028        return base_step;
2029    }
2030    let pow = abs.log10().floor();
2031    let magnitude = 10f64.powf(pow);
2032    let adaptive = T::from_f64_saturating(magnitude);
2033    // Fall back to the user's base step if adaptive truncates to
2034    // zero (possible for integer types when pow < 0).
2035    let adaptive_f = adaptive.to_f64();
2036    if adaptive_f.abs() < 1e-12 {
2037        base_step
2038    } else {
2039        adaptive
2040    }
2041}
2042
2043/// Approximate equality. Integer types compare bit-exactly;
2044/// floats tolerate sub-unit-in-last-place jitter. Used throughout
2045/// to suppress redundant signal sets.
2046fn approx_eq<T: SpinValue>(a: T, b: T) -> bool {
2047    if T::is_integer() {
2048        a.to_f64() == b.to_f64()
2049    } else {
2050        // Relative epsilon scaled by value magnitude so both near-zero
2051        // and large-value comparisons behave.
2052        let af = a.to_f64();
2053        let bf = b.to_f64();
2054        let scale = af.abs().max(bf.abs()).max(1.0);
2055        (af - bf).abs() <= scale * 1e-9
2056    }
2057}
2058
2059fn approx_ne<T: SpinValue>(a: T, b: T) -> bool {
2060    !approx_eq(a, b)
2061}
2062
2063/// Measure the advance width of `text` in logical pixels using the
2064/// app-wide `SharedTypesetter` (the same backend the field paints
2065/// with). Falls back to a rough heuristic when no typesetter is
2066/// installed (headless tests) so the caller still gets a non-zero
2067/// width and the resolved `pixel_cap` behaves reasonably.
2068fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
2069    if text.is_empty() {
2070        return 0.0;
2071    }
2072    if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
2073        let backend = ts.as_text_backend();
2074        let layout = backend.borrow_mut().layout_single_line(text, style, None);
2075        return layout.width;
2076    }
2077    // Headless fallback: ~0.55 × font size per ASCII char is a
2078    // close approximation for Inter Regular at body weight.
2079    text.chars().count() as f32 * style.size * 0.55
2080}