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). The value is *committed* on
21//!   [`Enter`](teksilo_core::event::Key::Enter) or on focus loss —
22//!   at commit time the text is parsed, clamped into `[min, max]`
23//!   (or wrapped, per [`WrapMode`]), and reformatted. Invalid input
24//!   reverts to the last known good value.
25//! - **Keyboard**:
26//!   - `Up` / `Down` → ±[`single_step`](SpinBox::single_step)
27//!   - `PageUp` / `PageDown` → ±[`page_step`](SpinBox::page_step)
28//!     (default: `10 × single_step`)
29//!   - `Enter` → commit (stays focused)
30//!   - `Home` / `End` stay bound to the text cursor (Qt-compatible).
31//! - **Mouse wheel**: adjusts by `single_step` — wheel **down**
32//!   decreases, wheel **up** increases, matching `QAbstractSpinBox`,
33//!   `GtkSpinButton` and WinUI's `NumberBox`. Gated by
34//!   [`wheel_mode`](SpinBox::wheel_mode) (default: only when
35//!   focused, to avoid accidental scroll changes).
36//! - **Buttons**: up/down buttons stack to the right of the field
37//!   by default; can be hidden with
38//!   [`button_layout`](SpinBox::button_layout).
39//! - **Special value text**: when the current value equals `min`
40//!   and [`special_value_text`](SpinBox::special_value_text) is
41//!   set, the field shows that string instead of the formatted
42//!   number — Qt's "Auto" / "None" / "Unlimited" affordance.
43//! - **Adaptive step**: with
44//!   [`StepType::Adaptive`], the effective step
45//!   tracks the decimal magnitude of the current value (Qt's
46//!   `AdaptiveDecimalStepType`). Useful for values that span many
47//!   orders of magnitude in the same control.
48//! - **Locale**: the number follows the active locale's decimal
49//!   separator, digits and minus sign
50//!   ([`localized`](SpinBox::localized), on by default); thousands
51//!   separators are opt-in
52//!   ([`use_grouping`](SpinBox::use_grouping), off by default, as in
53//!   Qt). Display, commit parse and the per-character input filter
54//!   all resolve from one `NumberPresentation`, so they cannot
55//!   disagree about which separator the field is using — a French
56//!   user sees `12,5`, types `12,5`, and the numeric keypad's `.`
57//!   still works. Rendering is a string transform over the value's
58//!   own `Display`, never an `f64` round-trip, so a `SpinBox<i64>`
59//!   stays exact past 2^53. Turn it off for a number that is an
60//!   *identifier* rather than a quantity (port, version component,
61//!   database id). With no `I18nManager` installed the active locale
62//!   is the C locale and this is a no-op.
63//! - **Custom formatter / parser**: full override via
64//!   [`text_from_value`](SpinBox::text_from_value) and
65//!   [`value_from_text`](SpinBox::value_from_text); together they
66//!   let you implement currency, percentages with stored fraction,
67//!   hex, duration, anything. A custom formatter/parser owns the
68//!   whole convention — it is not re-punctuated by the locale layer.
69//!
70//! # Accessibility
71//!
72//! The composite exposes itself as
73//! [`Role::SpinButton`](teksilo_core::accesskit::Role::SpinButton)
74//! with numeric value, min, max, step, and jump properties set on
75//! the AccessKit node; the AT receives
76//! [`Increment`](teksilo_core::accesskit::Action::Increment),
77//! [`Decrement`](teksilo_core::accesskit::Action::Decrement),
78//! [`SetValue`](teksilo_core::accesskit::Action::SetValue), and
79//! [`Focus`](teksilo_core::accesskit::Action::Focus) actions. The
80//! step buttons are structurally part of the SpinBox and publish
81//! no separate a11y nodes.
82//!
83//! # Example
84//!
85//! ```ignore
86//! use teksilo::widgets::{SpinBox, WrapMode};
87//!
88//! let font_size = ctx.signal(12_i32);
89//! ctx.add(
90//!     SpinBox::new(font_size, 4, 72)
91//!         .single_step(1)
92//!         .page_step(10)
93//!         .suffix(" pt"),
94//! );
95//!
96//! let gain_db = ctx.signal(0.0_f32);
97//! ctx.add(
98//!     SpinBox::new(gain_db, -60.0, 12.0)
99//!         .single_step(0.5)
100//!         .decimals(1)
101//!         .suffix(" dB")
102//!         .wrap_mode(WrapMode::Clamp),
103//! );
104//! ```
105
106mod step_button;
107#[cfg(test)]
108mod tests;
109mod value;
110
111use std::rc::Rc;
112
113pub use self::value::SpinValue;
114
115use teksilo_canvas::{Path, Point, Rect, Size, SizeProposal};
116use teksilo_core::accessibility::AccessNodeBuilder;
117use teksilo_core::build_context::BuildContext;
118use teksilo_core::event::{EventResponse, Key, ScrollDelta, WidgetEvent};
119use teksilo_core::signal::{Prop, Signal};
120use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
121use teksilo_core::widget_builder::HandlerSet;
122use teksilo_core::widget_id::WidgetId;
123use teksilo_text::SharedTypesetter;
124use teksilo_tokens::{CornerRadius, TextStyle};
125
126use crate::primitives::icon_widget::IconWidget;
127use crate::primitives::text_input_field::TextInputField;
128use crate::primitives::{MinSize, Padding};
129
130use self::step_button::StepButton;
131
132// ── Enums ──────────────────────────────────────────────────────────
133
134/// Out-of-range behavior when stepping past `min` or `max`.
135///
136/// Set via [`SpinBox::wrap_mode`].
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum WrapMode {
139    /// Clamp to `min` / `max` (default).
140    #[default]
141    Clamp,
142    /// Wrap around: past `max` jumps to `min`, past `min` jumps to
143    /// `max`. Matches Qt's `QAbstractSpinBox::wrapping`.
144    Wrap,
145}
146
147/// Step-size policy for each key/button press.
148///
149/// Set via [`SpinBox::step_type`].
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum StepType {
152    /// Always step by `single_step` (default).
153    #[default]
154    Fixed,
155    /// Step by the decimal power-of-ten immediately below the
156    /// current value's magnitude — e.g. values 1–9 step by 1,
157    /// 10–99 by 10, 100–999 by 100. Matches Qt's
158    /// `AdaptiveDecimalStepType`. Integer types honor the same
159    /// rule using the magnitude of the absolute value.
160    Adaptive,
161}
162
163pub use teksilo_core::styles::ButtonLayout;
164use teksilo_i18n::LocalizedString;
165
166/// When the mouse wheel is allowed to adjust the value.
167///
168/// Set via [`SpinBox::wheel_mode`].
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
170pub enum WheelMode {
171    /// Wheel adjusts only when the field is focused. Default —
172    /// prevents accidental changes when the user is scrolling a
173    /// larger surrounding view.
174    #[default]
175    Focused,
176    /// Wheel adjusts whenever the pointer is over the widget.
177    Hover,
178    /// Wheel never adjusts the value; events bubble to the
179    /// surrounding scroll container.
180    Disabled,
181}
182
183/// How the SpinBox decides its horizontal size envelope.
184///
185/// Chosen via the [`width`](SpinBox::width),
186/// [`width_chars`](SpinBox::width_chars), and
187/// [`fill_width`](SpinBox::fill_width) builder methods — the enum
188/// itself is the storage, not a separate public configuration
189/// API.
190#[derive(Debug, Clone)]
191pub enum WidthPolicy {
192    /// Cap the widget at a fixed logical-pixel width. Default is
193    /// `DEFAULT_PREFERRED_WIDTH` (120 dp), matching Qt's
194    /// `QSpinBox` sizeHint.
195    Pixels(f32),
196    /// Size the widget to fit this many reference digits (`'0'`)
197    /// plus the configured suffix, padding, and step buttons.
198    /// Measurement uses the theme font at build time.
199    Chars(u32),
200    /// Let the widget expand horizontally to fill whatever space
201    /// the parent offers. Equivalent to an infinite pixel cap.
202    Fill,
203}
204
205// ── Type aliases for builder closures ──────────────────────────────
206
207type TextFromValue<T> = Rc<dyn Fn(T) -> LocalizedString>;
208type ValueFromText<T> = Rc<dyn Fn(&str) -> Option<T>>;
209type OnValueChangedFn<T> = Rc<dyn Fn(T, &mut EventContext)>;
210
211/// Minimum total width. Below this the stacked step buttons stop
212/// fitting next to the field. Widgets narrower than this are
213/// enforced to the minimum at layout time via `MinSize`.
214const MIN_WIDTH_WITH_BUTTONS: f32 = 72.0;
215/// Minimum total width when buttons are hidden — the field alone
216/// plus padding still reads as a numeric control.
217const MIN_WIDTH_NO_BUTTONS: f32 = 48.0;
218/// Default maximum width. Matches Qt's `QSpinBox` sizeHint for a
219/// 4-digit value + unit suffix and stays tight in Int UI-style
220/// dense forms.
221const DEFAULT_PREFERRED_WIDTH: f32 = 120.0;
222
223// ── SpinBox ────────────────────────────────────────────────────────
224
225/// Numeric input with step buttons. Generic over
226/// [`SpinValue`] — pre-implemented for `i32`, `i64`, `u32`, `u64`,
227/// `usize`, `f32`, and `f64`.
228pub struct SpinBox<T: SpinValue> {
229    // ── Required configuration ──────────────────────────────────────
230    value: Signal<T>,
231    min: T,
232    max: T,
233
234    // ── Optional configuration (builders) ───────────────────────────
235    single_step: T,
236    page_step: Option<T>,
237    decimals: u8,
238    suffix: String,
239    /// Whether the displayed number follows the active locale's
240    /// conventions. See [`localized`](SpinBox::localized).
241    localized: bool,
242    /// Whether the displayed number carries thousands separators.
243    /// Off by default — see [`use_grouping`](SpinBox::use_grouping).
244    use_grouping: bool,
245    special_value_text: Option<LocalizedString>,
246    wrap_mode: WrapMode,
247    step_type: StepType,
248    button_layout: ButtonLayout,
249    wheel_mode: WheelMode,
250    /// Horizontal sizing policy. One of [`WidthPolicy::Pixels`]
251    /// (fixed cap), [`WidthPolicy::Chars`] (font-metric-based),
252    /// or [`WidthPolicy::Fill`] (stretch to parent). Set by the
253    /// [`width`](SpinBox::width), [`width_chars`](SpinBox::width_chars),
254    /// and [`fill_width`](SpinBox::fill_width) builder methods.
255    width_policy: WidthPolicy,
256    label: Option<LocalizedString>,
257    placeholder: LocalizedString,
258    /// Enabled state, static or reactive; forwarded to the arena at
259    /// build time. Also captured as a build-time snapshot for the
260    /// several build-time decisions inside `build()` that need a
261    /// plain `bool` (seeding the inner `TextInputField`'s read-only
262    /// mode, deriving the step buttons' enabled signals, and gating
263    /// the key-preview / scroll handlers alongside `read_only`).
264    enabled: Prop<bool>,
265    read_only: bool,
266    text_from_value: Option<TextFromValue<T>>,
267    value_from_text: Option<ValueFromText<T>>,
268    on_value_changed: Option<OnValueChangedFn<T>>,
269
270    // ── Internal state (set during build) ───────────────────────────
271    text_signal: Signal<String>,
272    /// Tracks whether any descendant of the SpinBox root holds focus —
273    /// in practice, the inner `TextInputField`. Driven by
274    /// `WidgetBuilder::focus_within` on the root frame; replaces the
275    /// previous multi-state `InteractionState` signal that was piped
276    /// in/out of the field via `interaction_signal()`. The outer SpinBox
277    /// only ever cared about Focused vs not.
278    focused: Signal<bool>,
279    can_step_up: Signal<bool>,
280    can_step_down: Signal<bool>,
281    /// Cached horizontal cap in pixels, resolved from `width_policy`
282    /// at build time (Chars mode measures the theme font). `None`
283    /// when the policy is `Fill`. Applied by `size_that_fits` by
284    /// narrowing the proposal before delegating to the child — this
285    /// replaces wrapping the subtree in a `MaxSize`, which clips
286    /// children and would truncate the focus-state border stroke
287    /// against its own shape quad.
288    pixel_cap: Option<f32>,
289    /// Floor width so the field and step buttons always fit. Also
290    /// resolved at build from `button_layout`.
291    min_width: f32,
292    /// Per-call style override for the SpinBox chrome. Higher
293    /// precedence than the theme-wide `style_slots.spin_box` slot.
294    style_override: Option<teksilo_core::styles::SharedSpinBoxStyle>,
295    root_child_id: Option<WidgetId>,
296    field_id: Option<WidgetId>,
297
298    // ── Tooltip slots (mutually exclusive; last setter wins) ─────────
299    /// Optional plain tooltip text shown after a hover delay. Mutually
300    /// exclusive with the rich / composite slots — every setter clears
301    /// the other two so the last call wins.
302    tooltip_text: Option<LocalizedString>,
303    /// Optional rich tooltip source (registry key or inline content).
304    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
305    /// Optional composite tooltip body (arbitrary widget tree).
306    composite_tooltip_content: Option<Box<dyn Widget>>,
307}
308
309impl<T: SpinValue> std::fmt::Debug for SpinBox<T> {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("SpinBox")
312            .field("min", &self.min)
313            .field("max", &self.max)
314            .field("single_step", &self.single_step)
315            .field("decimals", &self.decimals)
316            .field("localized", &self.localized)
317            .field("use_grouping", &self.use_grouping)
318            .field("wrap_mode", &self.wrap_mode)
319            .finish_non_exhaustive()
320    }
321}
322
323impl<T: SpinValue> SpinBox<T> {
324    /// Construct a new SpinBox bound to `value` with the given
325    /// inclusive range. `min` must be ≤ `max`.
326    pub fn new(value: Signal<T>, min: T, max: T) -> Self {
327        // `single_step` defaults to 1 on the value's f64 scale —
328        // works naturally for integers and for decimal floats.
329        // Callers with a different natural step (0.1, 0.5, 10, …)
330        // override via `single_step(...)`.
331        let default_step = T::from_f64_saturating(1.0);
332        Self {
333            value,
334            min,
335            max,
336            single_step: default_step,
337            page_step: None,
338            decimals: if T::is_integer() { 0 } else { 2 },
339            suffix: String::new(),
340            localized: true,
341            use_grouping: false,
342            special_value_text: None,
343            wrap_mode: WrapMode::Clamp,
344            step_type: StepType::Fixed,
345            button_layout: ButtonLayout::Stacked,
346            wheel_mode: WheelMode::Focused,
347            width_policy: WidthPolicy::Pixels(DEFAULT_PREFERRED_WIDTH),
348            label: None,
349            placeholder: LocalizedString::literal(String::new()),
350            enabled: Prop::Static(true),
351            read_only: false,
352            text_from_value: None,
353            value_from_text: None,
354            on_value_changed: None,
355            text_signal: Signal::new(String::new()),
356            focused: Signal::new(false),
357            can_step_up: Signal::new(true),
358            can_step_down: Signal::new(true),
359            pixel_cap: None,
360            min_width: MIN_WIDTH_WITH_BUTTONS,
361            style_override: None,
362            root_child_id: None,
363            field_id: None,
364            tooltip_text: None,
365            rich_tooltip_source: None,
366            composite_tooltip_content: None,
367        }
368    }
369
370    /// Per-call style override. Higher precedence than the theme-wide
371    /// `style_slots.spin_box` slot.
372    pub fn style(mut self, style: impl teksilo_core::styles::SpinBoxStyle) -> Self {
373        self.style_override = Some(Rc::new(style));
374        self
375    }
376
377    // ── Builder methods ─────────────────────────────────────────────
378
379    /// Set the step size for `Up` / `Down` / single wheel tick /
380    /// button tap.
381    pub fn single_step(mut self, step: T) -> Self {
382        self.single_step = step;
383        self
384    }
385
386    /// Set the step size for `PageUp` / `PageDown`. When unset,
387    /// defaults to `10 × single_step` at build time.
388    pub fn page_step(mut self, step: T) -> Self {
389        self.page_step = Some(step);
390        self
391    }
392
393    /// Number of decimal places shown for floating-point types.
394    /// Ignored for integer types.
395    pub fn decimals(mut self, decimals: u8) -> Self {
396        self.decimals = decimals;
397        self
398    }
399
400    /// Whether the number follows the active locale's conventions —
401    /// decimal separator, digits, and minus sign. **On by default.**
402    ///
403    /// A French user sees `12,5`, not `12.5`, and can type either: the
404    /// commit path de-localizes before parsing, and the input filter
405    /// accepts both the locale's separator and the ASCII one, so a
406    /// numeric keypad still works.
407    ///
408    /// Turn it **off** for a number that is an identifier rather than a
409    /// quantity — a port number, a version component, a database id, a
410    /// pixel offset in a file format. Those read wrong grouped or
411    /// re-punctuated, and their conventional form is the C-locale one.
412    ///
413    /// Localization is a string transform over the value's own
414    /// `Display`, not a round-trip through `f64`, so a `SpinBox<i64>`
415    /// keeps full precision past 2^53.
416    ///
417    /// With no `I18nManager` installed the active locale resolves to the
418    /// C locale, so this is a no-op in tests and in apps that have not
419    /// opted into i18n.
420    pub fn localized(mut self, on: bool) -> Self {
421        self.localized = on;
422        self
423    }
424
425    /// Whether the displayed number carries thousands separators.
426    /// **Off by default**, matching Qt (`QAbstractSpinBox::
427    /// isGroupSeparatorShown` is false unless asked for).
428    ///
429    /// Separators help a large read-only quantity and get in the way of
430    /// a field being typed into, so this is opt-in per SpinBox rather
431    /// than a locale-wide default. Grouping follows the locale's own
432    /// group sizes, including the Indic lakh system (`12,34,567`).
433    ///
434    /// Has no effect when [`localized`](Self::localized) is off.
435    pub fn use_grouping(mut self, on: bool) -> Self {
436        self.use_grouping = on;
437        self
438    }
439
440    /// Qt-style non-editable trailing unit (e.g. `" %"`, `" px"`,
441    /// `" dB"`). Rendered flush-right inside the field's border;
442    /// the caret cannot enter it.
443    pub fn suffix(mut self, text: impl Into<String>) -> Self {
444        self.suffix = text.into();
445        self
446    }
447
448    /// Text shown in place of the formatted value when the current
449    /// value equals `min`. Use for "Auto", "None", "Off",
450    /// "Unlimited" affordances where the minimum has special
451    /// semantics. When the field is focused the real number is
452    /// shown instead so the user can type.
453    pub fn special_value_text(mut self, text: impl Into<LocalizedString>) -> Self {
454        self.special_value_text = Some(text.into());
455        self
456    }
457
458    /// Set the out-of-range behavior when stepping past `min` or `max`
459    /// (default: `Clamp`).
460    pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
461        self.wrap_mode = mode;
462        self
463    }
464
465    /// Set the step-size policy (default: `Fixed`). Use
466    /// `StepType::Adaptive` for values that span many orders of magnitude.
467    pub fn step_type(mut self, step_type: StepType) -> Self {
468        self.step_type = step_type;
469        self
470    }
471
472    /// Override the step-button layout (default: `Stacked` — stacked
473    /// up/down buttons to the right of the field).
474    pub fn button_layout(mut self, layout: ButtonLayout) -> Self {
475        self.button_layout = layout;
476        self
477    }
478
479    /// Convenience wrapper over [`button_layout`](Self::button_layout):
480    /// `true` → `ButtonLayout::Stacked`, `false` → `ButtonLayout::Hidden`.
481    /// Matches the Int UI guideline that SpinBoxes in dense forms
482    /// often hide the step buttons to reduce visual noise and let
483    /// keyboard / wheel carry the affordance — pass
484    /// `.show_buttons(false)` on those call sites.
485    pub fn show_buttons(mut self, show: bool) -> Self {
486        self.button_layout = if show {
487            ButtonLayout::Stacked
488        } else {
489            ButtonLayout::Hidden
490        };
491        self
492    }
493
494    /// Set when the mouse wheel adjusts the value (default: `Focused` —
495    /// only when the inner field holds focus).
496    pub fn wheel_mode(mut self, mode: WheelMode) -> Self {
497        self.wheel_mode = mode;
498        self
499    }
500
501    /// Cap the widget's horizontal size at a fixed logical-pixel
502    /// width. If the parent offers less, the SpinBox shrinks (down
503    /// to the internal 72 dp / 48 dp floor that keeps the buttons
504    /// and field from overlapping). Default: 120 dp, matching Qt
505    /// `QSpinBox` sizeHint and Int UI form density.
506    ///
507    /// ```rust
508    /// # use teksilo_widgets::SpinBox;
509    /// # use teksilo_core::signal::Signal;
510    /// # let v = Signal::new(0_i32);
511    /// let _w = SpinBox::new(v.clone(), 0, 9999).width(80.0);        // narrow
512    /// let _w = SpinBox::new(v.clone(), 0, 9999).width(200.0);       // wider
513    /// let _w = SpinBox::new(v.clone(), 0, 9999).fill_width();       // stretch to parent
514    /// let _w = SpinBox::new(v.clone(), 0, 9999).width_chars(5);     // "fits 5 digits"
515    /// ```
516    pub fn width(mut self, width: f32) -> Self {
517        self.width_policy = WidthPolicy::Pixels(width.max(0.0));
518        self
519    }
520
521    /// Size the widget to fit exactly `chars` reference digits plus
522    /// the configured suffix, padding, and step buttons. The
523    /// measurement uses the actual theme font at build time (same
524    /// `SharedTypesetter` the field draws with), so values stay
525    /// right under runtime theme switches and HiDPI scale changes.
526    ///
527    /// ```rust
528    /// # use teksilo_widgets::SpinBox;
529    /// # use teksilo_core::signal::Signal;
530    /// # let port = Signal::new(8080_i32);
531    /// # let pct = Signal::new(0_i32);
532    /// let _w = SpinBox::new(port, 0, 65_535).width_chars(5);           // 5 digits
533    /// let _w = SpinBox::new(pct, 0, 100).suffix(" %").width_chars(3);  // 3 + " %"
534    /// ```
535    pub fn width_chars(mut self, chars: u32) -> Self {
536        self.width_policy = WidthPolicy::Chars(chars);
537        self
538    }
539
540    /// Let the widget expand to fill the horizontal space offered
541    /// by its parent, instead of capping at [`width`](Self::width).
542    /// Use inside toolbars, inspector panels, or an
543    /// `Expand::horizontal` column that should stretch with the
544    /// surrounding layout.
545    pub fn fill_width(mut self) -> Self {
546        self.width_policy = WidthPolicy::Fill;
547        self
548    }
549
550    /// Set the accessible name announced by screen readers as the
551    /// control's label. ARIA requires spin buttons to have a label;
552    /// when none is set here the caller is responsible for labelling
553    /// via a wrapping element or `access_label`.
554    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
555        let ls: LocalizedString = label.into();
556        self.label = Some(ls);
557        self
558    }
559
560    /// Set the placeholder text shown in the field when it is empty.
561    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
562        let ls: LocalizedString = text.into();
563        self.placeholder = ls;
564        self
565    }
566
567    /// Set the enabled state, statically or reactively. Forwarded to
568    /// the arena at build time via
569    /// `ctx.enabled_when(spinbox_id, self.enabled.clone())`.
570    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
571        self.enabled = enabled.into();
572        self
573    }
574
575    /// Prevent the user from typing in the field while still allowing
576    /// keyboard and button stepping.
577    pub fn read_only(mut self, read_only: bool) -> Self {
578        self.read_only = read_only;
579        self
580    }
581
582    /// Override the value → display-string conversion. Receives the
583    /// raw value; returns whatever string should appear in the
584    /// field. Suffix and `special_value_text` still apply on top of
585    /// the returned string.
586    pub fn text_from_value(mut self, f: impl Fn(T) -> LocalizedString + 'static) -> Self {
587        self.text_from_value = Some(Rc::new(f));
588        self
589    }
590
591    /// Override the parse step. Receives the field's raw text
592    /// (without the suffix, which is never part of the editable
593    /// content); returns `Some(value)` to accept or `None` to
594    /// reject. Invalid input reverts to the last good value on
595    /// commit.
596    pub fn value_from_text(mut self, f: impl Fn(&str) -> Option<T> + 'static) -> Self {
597        self.value_from_text = Some(Rc::new(f));
598        self
599    }
600
601    /// Closure fired each time the value is committed (keyboard
602    /// step, button tap, wheel tick, Enter, blur). Bound observers
603    /// on the value signal also see every change; use this hook
604    /// when the caller needs an `EventContext` (e.g. to fire an
605    /// intent).
606    pub fn on_value_changed(mut self, f: impl Fn(T, &mut EventContext) + 'static) -> Self {
607        self.on_value_changed = Some(Rc::new(f));
608        self
609    }
610
611    // ── Tooltip builder methods ─────────────────────────────────────
612
613    /// Attach a plain single-line tooltip shown after a hover delay.
614    ///
615    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
616    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
617    /// [`composite_tooltip`](Self::composite_tooltip) — each setter
618    /// clears the other two so the last call wins.
619    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
620        self.tooltip_text = Some(text.into());
621        self.rich_tooltip_source = None;
622        self.composite_tooltip_content = None;
623        self
624    }
625
626    /// Attach a rich tooltip looked up by registry key.
627    ///
628    /// The key must match a [`TooltipContent`](crate::tooltip::TooltipContent)
629    /// registered in the application's tooltip registry. Mutually
630    /// exclusive with [`tooltip`](Self::tooltip),
631    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
632    /// [`composite_tooltip`](Self::composite_tooltip).
633    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
634        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
635        self.tooltip_text = None;
636        self.composite_tooltip_content = None;
637        self
638    }
639
640    /// Attach a rich tooltip with inline content (no registry key
641    /// required). Mutually exclusive with [`tooltip`](Self::tooltip),
642    /// [`rich_tooltip`](Self::rich_tooltip), and
643    /// [`composite_tooltip`](Self::composite_tooltip).
644    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
645        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
646        self.tooltip_text = None;
647        self.composite_tooltip_content = None;
648        self
649    }
650
651    /// Attach a composite tooltip whose body is an arbitrary widget
652    /// tree. Mutually exclusive with [`tooltip`](Self::tooltip),
653    /// [`rich_tooltip`](Self::rich_tooltip), and
654    /// [`rich_tooltip_content`](Self::rich_tooltip_content).
655    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
656        self.composite_tooltip_content = Some(Box::new(content));
657        self.tooltip_text = None;
658        self.rich_tooltip_source = None;
659        self
660    }
661
662    /// Like [`composite_tooltip`](Self::composite_tooltip) but accepts
663    /// an already-boxed widget body. Used by wrapper widgets that
664    /// forward a boxed composite body.
665    pub(crate) fn composite_tooltip_boxed(mut self, content: Box<dyn Widget>) -> Self {
666        self.composite_tooltip_content = Some(content);
667        self.tooltip_text = None;
668        self.rich_tooltip_source = None;
669        self
670    }
671
672    // ── Signal accessors (call before add to tree) ──────────────────
673
674    /// The bound numeric value signal.
675    pub fn value(&self) -> Signal<T> {
676        self.value.clone()
677    }
678}
679
680impl<T: SpinValue> Widget for SpinBox<T> {
681    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
682        // Sanity-check the range once. A malformed range is a
683        // programming error, not a runtime user error.
684        debug_assert!(self.min <= self.max, "SpinBox min must be <= max");
685
686        // SpinBox reads theme tokens once for static layout params
687        // (padding, focus-ring width, body typography). Colors resolve
688        // through role props against the current theme at paint time,
689        // so runtime theme switches re-paint without riding through
690        // per-widget zips.
691        //
692        // The snapshot is OWNED (`theme_signal.get()` clones), because the
693        // body typography is read further down in `build()` — past many
694        // `ctx.add(...)` / `ctx.effect(...)` calls that need a mutable
695        // borrow of `ctx`. A `ctx.theme()` borrow would hold `ctx`
696        // immutable across those calls and fail the borrow checker.
697        let theme = ctx.theme_signal().get();
698        use crate::styles::recipe_text_input_style as field_dims;
699        let field_border_width = field_dims::TEXT_FIELD_BORDER_WIDTH;
700        let focus_ring_width = theme.shape.focus_ring_width;
701
702        // Capture configuration into owned clones for the effect
703        // closures. The builder closures are `Rc`-wrapped already.
704        let min = self.min;
705        let max = self.max;
706        let decimals = self.decimals;
707        let suffix_str = self.suffix.clone();
708        let special_text = self.special_value_text.clone();
709        let text_from_value = self.text_from_value.clone();
710        let value_from_text = self.value_from_text.clone();
711        let wrap_mode = self.wrap_mode;
712        let step_type = self.step_type;
713        let single_step = self.single_step;
714        let page_step = self
715            .page_step
716            .unwrap_or_else(|| single_step.saturating_mul_u32(10));
717        let on_value_changed = self.on_value_changed.clone();
718        let self_id = ctx.self_id();
719        // Forward the enabled state into the arena; see IconButton.
720        ctx.enabled_when(self_id, self.enabled.clone());
721        // Snapshot the build-time enabled state into a local for
722        // closures that capture it for read-only-style guards on
723        // wheel events / step buttons. The framework's event gate
724        // already refuses to dispatch to disabled subtrees, so the
725        // value here only matters for the few build-time decisions
726        // (e.g. seeding the inner TextInputField's read_only mode).
727        let enabled = self.enabled.get();
728        let read_only = self.read_only;
729        let wheel_mode = self.wheel_mode;
730
731        // Resolve the locale's number conventions once, and hand the
732        // *same* value to the display path, the commit parse and the
733        // input filter. Split resolution would let the three disagree
734        // about which separator this field uses — the failure mode
735        // `DateEdit` avoids by deriving format, parse and mask from one
736        // `ParsedPattern`.
737        //
738        // Rebuild-level reactivity is not needed: the effect on
739        // `ctx.locale_signal()` below re-formats the text in place, and
740        // the closures below re-resolve on the next build.
741        let presentation = NumberPresentation::resolve(self.localized, self.use_grouping);
742
743        // Seed the text signal from the current value.
744        {
745            let initial = format_for_display(
746                self.value.get(),
747                decimals,
748                special_text.as_ref(),
749                text_from_value.as_deref(),
750                min,
751                false,
752                &presentation,
753            );
754            self.text_signal.set(initial);
755        }
756
757        // Effect: when the value signal changes externally, reformat
758        // the text. This also fires on startup with the initial value
759        // (guaranteed by `ctx.effect`). Skipped when the field is
760        // focused so typing isn't interrupted by our own round-trip
761        // writes — on commit we explicitly re-sync.
762        {
763            let text_signal = self.text_signal.clone();
764            let text_from_value = text_from_value.clone();
765            let special_text = special_text.clone();
766            let focused = self.focused.clone();
767            let can_up = self.can_step_up.clone();
768            let can_down = self.can_step_down.clone();
769            let min_cap = min;
770            let max_cap = max;
771            let presentation = presentation.clone();
772            ctx.effect(&self.value, move |new_value| {
773                // Update the can-step signals any time the value
774                // changes so the buttons and a11y reflect whether
775                // further stepping is possible under clamp mode.
776                let is_focused = focused.get();
777                if wrap_mode == WrapMode::Wrap {
778                    can_up.set(true);
779                    can_down.set(true);
780                } else {
781                    can_up.set(*new_value < max_cap);
782                    can_down.set(*new_value > min_cap);
783                }
784                if !is_focused {
785                    let formatted = format_for_display(
786                        *new_value,
787                        decimals,
788                        special_text.as_ref(),
789                        text_from_value.as_deref(),
790                        min_cap,
791                        false,
792                        &presentation,
793                    );
794                    if text_signal.get() != formatted {
795                        text_signal.set(formatted);
796                    }
797                }
798            });
799        }
800
801        // Effect: re-format on locale change so special_value_text
802        // and custom formatters re-resolve with the new locale.
803        {
804            let text_signal = self.text_signal.clone();
805            let text_from_value = text_from_value.clone();
806            let special_text = special_text.clone();
807            let value_signal = self.value.clone();
808            let focused = self.focused.clone();
809            let locale_signal = ctx.locale_signal();
810            // A locale switch re-renders the number in place. The
811            // presentation resolved at build time is stale by then, so
812            // re-resolve inside the effect rather than capturing it.
813            let localized = self.localized;
814            let grouping = self.use_grouping;
815            ctx.effect(&locale_signal, move |_| {
816                let presentation = NumberPresentation::resolve(localized, grouping);
817                let formatted = format_for_display(
818                    value_signal.get(),
819                    decimals,
820                    special_text.as_ref(),
821                    text_from_value.as_deref(),
822                    min,
823                    focused.get(),
824                    &presentation,
825                );
826                if text_signal.get() != formatted {
827                    text_signal.set(formatted);
828                }
829            });
830        }
831
832        // Commit helper: called on Enter and on blur. Parses the
833        // current text; on success, clamps and writes the value and
834        // reformats the text. On failure, reverts the text to the
835        // formatted current value.
836        let commit: Rc<dyn Fn(&mut EventContext)> = {
837            let value_signal = self.value.clone();
838            let text_signal = self.text_signal.clone();
839            let value_from_text = value_from_text.clone();
840            let text_from_value = text_from_value.clone();
841            let special_text = special_text.clone();
842            let on_value_changed = on_value_changed.clone();
843            let commit_presentation = presentation.clone();
844            Rc::new(move |ctx: &mut EventContext| {
845                let raw = text_signal.get();
846                // A user-supplied parser gets the raw text: it owns the
847                // whole convention, and de-localizing first would hand it
848                // a string it never agreed to read.
849                let parsed: Option<T> = match value_from_text.as_deref() {
850                    Some(f) => f(raw.trim()),
851                    None => commit_presentation.parse::<T>(&raw),
852                };
853                let old = value_signal.get();
854                let new_value = match parsed {
855                    Some(v) => v.clamp_value(min, max),
856                    None => old, // revert
857                };
858                let formatted = format_for_display(
859                    new_value,
860                    decimals,
861                    special_text.as_ref(),
862                    text_from_value.as_deref(),
863                    min,
864                    false,
865                    &commit_presentation,
866                );
867                if text_signal.get() != formatted {
868                    text_signal.set(formatted);
869                }
870                if approx_ne(new_value, old) {
871                    value_signal.set(new_value);
872                    if let Some(cb) = on_value_changed.as_ref() {
873                        cb(new_value, ctx);
874                    }
875                }
876            })
877        };
878
879        // ── Step helpers ───────────────────────────────────────────
880        //
881        // Two closures cover the two firing pathways:
882        //
883        // - `step` is called from event handlers (keyboard, wheel,
884        //   button tap, a11y action) and takes an `EventContext`
885        //   so it can fire the user's `on_value_changed` callback
886        //   and request a frame.
887        //
888        // - `step_silent` is called from signal-only contexts
889        //   (hold-to-repeat on the step buttons, which lives in a
890        //   frame-tick effect that has no `EventContext` to hand).
891        //   It mutates `value` and `text_signal` and lets the
892        //   bindings on those signals trigger the redraw. The
893        //   user's `on_value_changed` callback is deliberately
894        //   skipped — signal observers still see every change,
895        //   which is the primary notification channel.
896        //
897        // `can_step_up` / `can_step_down` are kept in sync by the
898        // value-effect above.
899
900        fn apply_step<T: SpinValue>(
901            dir: i32,
902            page: bool,
903            step_type: StepType,
904            wrap_mode: WrapMode,
905            single_step: T,
906            page_step: T,
907            min: T,
908            max: T,
909            current: T,
910        ) -> T {
911            let base_step = if page { page_step } else { single_step };
912            let effective = resolve_effective_step(step_type, current, base_step);
913            let stepped = if dir > 0 {
914                current.saturating_add(effective)
915            } else {
916                current.saturating_sub(effective)
917            };
918            if stepped < min || stepped > max {
919                match wrap_mode {
920                    WrapMode::Clamp => stepped.clamp_value(min, max),
921                    WrapMode::Wrap => {
922                        if stepped > max {
923                            min
924                        } else {
925                            max
926                        }
927                    }
928                }
929            } else {
930                stepped
931            }
932        }
933
934        // Signal-only step: mutates `value` and `text_signal` and
935        // returns the previous/new pair so the caller can fire any
936        // extra side-effect (e.g. `on_value_changed`) after the
937        // fact. When nothing changed returns `None`.
938        let step_silent: Rc<dyn Fn(i32, bool) -> Option<T>> = {
939            let value_signal = self.value.clone();
940            let text_signal = self.text_signal.clone();
941            let text_from_value = text_from_value.clone();
942            let special_text = special_text.clone();
943            let presentation = presentation.clone();
944            Rc::new(move |dir: i32, page: bool| {
945                if read_only {
946                    return None;
947                }
948                let current = value_signal.get();
949                let new_value = apply_step(
950                    dir,
951                    page,
952                    step_type,
953                    wrap_mode,
954                    single_step,
955                    page_step,
956                    min,
957                    max,
958                    current,
959                );
960                if approx_eq(new_value, current) {
961                    return None;
962                }
963                value_signal.set(new_value);
964                let formatted = format_for_display(
965                    new_value,
966                    decimals,
967                    special_text.as_ref(),
968                    text_from_value.as_deref(),
969                    min,
970                    false,
971                    &presentation,
972                );
973                if text_signal.get() != formatted {
974                    text_signal.set(formatted);
975                }
976                Some(new_value)
977            })
978        };
979
980        // Contextful step: wraps `step_silent` and fires the user
981        // callback + frame request on change.
982        let step: Rc<dyn Fn(i32, bool, &mut EventContext)> = {
983            let step_silent = step_silent.clone();
984            let on_value_changed = on_value_changed.clone();
985            Rc::new(move |dir: i32, page: bool, ctx: &mut EventContext| {
986                if let Some(new_value) = step_silent(dir, page) {
987                    if let Some(cb) = on_value_changed.as_ref() {
988                        cb(new_value, ctx);
989                    }
990                    ctx.request_frame();
991                }
992            })
993        };
994
995        // ── Inner editing field ────────────────────────────────────
996        //
997        // Uses `TextInputField` directly rather than the `TextInput`
998        // composite — the composite's border / padding / placeholder
999        // overlay is reproduced here around both the field and the
1000        // buttons in one shared frame, instead of framing the text
1001        // by itself.
1002        let inner_height =
1003            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
1004        let text_area_height =
1005            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
1006
1007        let mut field = TextInputField::new(self.text_signal.clone())
1008            .enabled(enabled)
1009            .read_only(read_only)
1010            .placeholder(self.placeholder.clone())
1011            .text_height(text_area_height)
1012            .char_filter({
1013                let presentation = presentation.clone();
1014                move |c| presentation.accepts_char::<T>(c)
1015            });
1016        // Suffix wiring:
1017        //   • plain static suffix              → `.suffix(..)` (no signal)
1018        //   • static suffix + special_value    → reactive: hide suffix when
1019        //     `value == min` AND the field isn't focused, so `"Auto"` reads
1020        //     cleanly without a trailing unit but typing at `min` still
1021        //     shows the unit. Matches Qt's `QSpinBox::specialValueText`
1022        //     behavior.
1023        //   • no suffix and no special         → nothing to do.
1024        //
1025        // The reactive case uses a mutable intermediate signal
1026        // rather than feeding `TextInputField::suffix` a
1027        // derived signal directly — `ctx.effect` requires a
1028        // mutable source, and the field needs to drive a
1029        // relayout + re-measure when the suffix flips on/off.
1030        if !suffix_str.is_empty() {
1031            if self.special_value_text.is_some() {
1032                let suffix_live = ctx.signal(suffix_str.clone());
1033                let resolve = {
1034                    let suffix_str = suffix_str.clone();
1035                    let min_cap = min;
1036                    move |v: T, focused: bool| -> String {
1037                        let at_min = approx_eq(v, min_cap);
1038                        if at_min && !focused {
1039                            String::new()
1040                        } else {
1041                            suffix_str.clone()
1042                        }
1043                    }
1044                };
1045                // Seed from the current state.
1046                {
1047                    let current_focused = self.focused.get();
1048                    suffix_live.set(resolve(self.value.get(), current_focused));
1049                }
1050                // Observe value.
1051                {
1052                    let suffix_live = suffix_live.clone();
1053                    let focused = self.focused.clone();
1054                    let resolve = resolve.clone();
1055                    ctx.effect(&self.value, move |v| {
1056                        let is_focused = focused.get();
1057                        let next = resolve(*v, is_focused);
1058                        if suffix_live.get() != next {
1059                            suffix_live.set(next);
1060                        }
1061                    });
1062                }
1063                // Observe focus.
1064                {
1065                    let suffix_live = suffix_live.clone();
1066                    let value_signal = self.value.clone();
1067                    let resolve = resolve.clone();
1068                    ctx.effect(&self.focused, move |is_focused| {
1069                        let next = resolve(value_signal.get(), *is_focused);
1070                        if suffix_live.get() != next {
1071                            suffix_live.set(next);
1072                        }
1073                    });
1074                }
1075                field = field.suffix(suffix_live);
1076            } else {
1077                field = field.suffix(suffix_str.clone());
1078            }
1079        }
1080        // Submit on Enter: commit in-place, keep focus.
1081        {
1082            let commit = commit.clone();
1083            field = field.on_submit_fn(move |ctx| commit(ctx));
1084        }
1085        // Commit on blur: after the field clears its selection and
1086        // resets scroll.
1087        {
1088            let commit = commit.clone();
1089            field = field.on_blur_fn(move |ctx| commit(ctx));
1090        }
1091
1092        // Also: when the field gains focus, show the raw editable
1093        // text instead of any `special_value_text`. TextInputField
1094        // itself doesn't know about our formatter so we bind a
1095        // secondary effect on the SpinBox's focus_within signal.
1096        {
1097            let focused_for_text = self.focused.clone();
1098            let text_signal = self.text_signal.clone();
1099            let value_signal = self.value.clone();
1100            let text_from_value = text_from_value.clone();
1101            let min_cap = min;
1102            ctx.effect(&focused_for_text, move |is_focused| {
1103                if *is_focused {
1104                    // On focus, swap any special_value_text out for
1105                    // the plain formatted number so the user can
1106                    // edit it with the keyboard.
1107                    let plain = format_for_display(
1108                        value_signal.get(),
1109                        decimals,
1110                        None,
1111                        text_from_value.as_deref(),
1112                        min_cap,
1113                        true,
1114                        &presentation,
1115                    );
1116                    if text_signal.get() != plain {
1117                        text_signal.set(plain);
1118                    }
1119                }
1120            });
1121        }
1122
1123        let field_id = ctx.add(field);
1124        self.field_id = Some(field_id);
1125
1126        // Wrap field in vertical padding so it aligns inside the frame.
1127        // (Horizontal Expand is owned by the active SpinBoxStyle so a
1128        // custom recipe can re-arrange the row.)
1129        let padded_field_id = ctx.add(
1130            Padding::new(
1131                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1132                0.0,
1133                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1134                0.0,
1135            )
1136            .child_id(field_id),
1137        );
1138
1139        // ── Step buttons ───────────────────────────────────────────
1140        let (step_up_id, step_down_id) = if self.button_layout != ButtonLayout::Hidden {
1141            let (u, d) = build_step_buttons(
1142                ctx,
1143                &step,
1144                &step_silent,
1145                self.can_step_up.clone(),
1146                self.can_step_down.clone(),
1147                enabled && !read_only,
1148                field_dims::TEXT_FIELD_HEIGHT,
1149                field_dims::TEXT_FIELD_CORNER_RADIUS,
1150            );
1151            (Some(u), Some(d))
1152        } else {
1153            (None, None)
1154        };
1155
1156        // ── Delegate visual chrome (row layout + divider + bordered
1157        // surface) to the active SpinBoxStyle.
1158        let style =
1159            crate::styles::recipe_spin_box_style::resolve_spin_box_style(&self.style_override, ctx);
1160        // Derive the disabled state from the arena rather than from the
1161        // build-time `enabled` snapshot above, so a bound `Signal<bool>`
1162        // (or a disabled *ancestor*) re-tints the chrome with no rebuild.
1163        let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
1164        let cfg = teksilo_core::styles::SpinBoxStyleConfig {
1165            field: padded_field_id,
1166            step_up: step_up_id,
1167            step_down: step_down_id,
1168            layout: self.button_layout,
1169            is_focused: self.focused.clone(),
1170            is_disabled,
1171        };
1172        let zstack_id = style.make_body(&cfg, ctx);
1173        let _ = focus_ring_width;
1174        let _ = field_border_width;
1175
1176        // Resolve the width policy into a concrete pixel cap (or
1177        // `None` for `Fill`). Char-mode measurement uses the app-
1178        // wide `SharedTypesetter` — same backend the field paints
1179        // with — so the result tracks runtime theme switches and
1180        // HiDPI scale changes. `'0'` is the reference digit since
1181        // Inter and most UI sans-serifs ship tabular-figure
1182        // numerals; the suffix is measured separately because it
1183        // may have different glyph advances (e.g. `" %"`).
1184        let min_width = match self.button_layout {
1185            ButtonLayout::Stacked => MIN_WIDTH_WITH_BUTTONS,
1186            ButtonLayout::Hidden => MIN_WIDTH_NO_BUTTONS,
1187        };
1188        let pixel_cap: Option<f32> = match self.width_policy {
1189            WidthPolicy::Fill => None,
1190            WidthPolicy::Pixels(px) => Some(px.max(min_width)),
1191            WidthPolicy::Chars(chars) => {
1192                let style = &theme.typography.body;
1193                let sample: String = "0".repeat(chars as usize);
1194                let digits_w = measure_width_px(ctx, &sample, style);
1195                let suffix_w = if suffix_str.is_empty() {
1196                    0.0
1197                } else {
1198                    measure_width_px(ctx, &suffix_str, style)
1199                };
1200                let button_chrome = match self.button_layout {
1201                    // 18 dp button + 4 dp divider padding + 1 dp divider
1202                    ButtonLayout::Stacked => 18.0 + 4.0 + 1.0,
1203                    ButtonLayout::Hidden => 0.0,
1204                };
1205                // 2 dp slack so the caret and a trailing zero never
1206                // paint flush against the right edge.
1207                let chrome = field_dims::TEXT_FIELD_PADDING_HORIZONTAL * 2.0 + button_chrome + 2.0;
1208                Some((digits_w + suffix_w + chrome).max(min_width))
1209            }
1210        };
1211
1212        // Size envelope:
1213        //   MinSize  → enforce a floor so the field and buttons
1214        //              still fit even when a narrow parent would
1215        //              otherwise squash the widget.
1216        //   The horizontal cap (when `pixel_cap` is `Some`) is
1217        //   applied by `SpinBox::size_that_fits` narrowing the
1218        //   proposal, NOT by wrapping in `MaxSize`. `MaxSize`
1219        //   clips its children, which would truncate the outer
1220        //   half of the focus-state border stroke against the
1221        //   widget's own shape quad (visible as a ring clipped on
1222        //   all four sides).
1223        let sized_id =
1224            ctx.add(MinSize::new(min_width, field_dims::TEXT_FIELD_HEIGHT).child_id(zstack_id));
1225        // Stash the resolved cap + floor on `self` for
1226        // `size_that_fits` to read at layout time.
1227        self.pixel_cap = pixel_cap;
1228        self.min_width = min_width;
1229
1230        // ── Root: attach key + wheel handlers on the outer sized id ─
1231        //
1232        // Bubble-phase `on_key` catches Up / Down / PageUp / PageDown
1233        // after the `TextInputField` declines them (the field's
1234        // keyboard dispatch falls through to `_ =>` for arrow keys,
1235        // returning `Ignored` so the bubble loop continues up).
1236        let root_id = sized_id;
1237        self.root_child_id = Some(root_id);
1238
1239        let step_for_key = step.clone();
1240        let step_for_wheel = step.clone();
1241        let value_for_a11y = self.value.clone();
1242        let field_id_for_access = field_id;
1243
1244        let handlers = HandlerSet::new()
1245            // The SpinBox is not itself focusable — focus lands inside the
1246            // inner TextInputField. `focus_within` writes `true` whenever
1247            // any descendant (in practice, the field) holds focus, driving
1248            // the unified outer focus ring + the suffix / text-formatting
1249            // effects that previously read an `interaction_signal` piped
1250            // out of the field.
1251            .focus_within(self.focused.clone())
1252            // Preview-pass dispatch — claims ArrowUp/ArrowDown/PageUp/PageDown
1253            // for stepping BEFORE the focused TextInputField sees them. The
1254            // bubble-pass `on_key` previously relied on the field happening
1255            // not to bind arrow keys; preview makes the contract explicit so
1256            // future field changes (multiline caret motion, etc.) cannot
1257            // silently break stepping. Non-arrow keys return `Ignored` and
1258            // fall through to the field for normal text input.
1259            .on_key_preview(move |event, ctx| {
1260                if !enabled || read_only {
1261                    return EventResponse::Ignored;
1262                }
1263                let WidgetEvent::KeyDown { key, .. } = event else {
1264                    return EventResponse::Ignored;
1265                };
1266                match key {
1267                    Key::ArrowUp => {
1268                        (step_for_key)(1, false, ctx);
1269                        EventResponse::Handled
1270                    }
1271                    Key::ArrowDown => {
1272                        (step_for_key)(-1, false, ctx);
1273                        EventResponse::Handled
1274                    }
1275                    Key::PageUp => {
1276                        (step_for_key)(1, true, ctx);
1277                        EventResponse::Handled
1278                    }
1279                    Key::PageDown => {
1280                        (step_for_key)(-1, true, ctx);
1281                        EventResponse::Handled
1282                    }
1283                    _ => EventResponse::Ignored,
1284                }
1285            })
1286            .on_scroll({
1287                let focused = self.focused.clone();
1288                move |event, ctx| {
1289                    if !enabled || read_only || wheel_mode == WheelMode::Disabled {
1290                        return EventResponse::Ignored;
1291                    }
1292                    // `Focused` wheel mode only fires when the
1293                    // inner field currently holds focus. `Hover` is
1294                    // the natural fallthrough — scroll events reach
1295                    // the widget only when the pointer is over it.
1296                    if wheel_mode == WheelMode::Focused && !focused.get() {
1297                        return EventResponse::Ignored;
1298                    }
1299                    let WidgetEvent::Scroll { delta, .. } = event else {
1300                        return EventResponse::Ignored;
1301                    };
1302                    let y = match delta {
1303                        ScrollDelta::Lines { y, .. } => *y,
1304                        ScrollDelta::Pixels { y, .. } => *y,
1305                    };
1306                    if y == 0.0 {
1307                        return EventResponse::Ignored;
1308                    }
1309                    // Teksilo's `ScrollDelta` is a *scroll offset* delta, not
1310                    // a raw wheel reading: `translate_mouse_wheel` negates
1311                    // winit's natural sign so that **positive y scrolls
1312                    // down** (the offset grows, content moves up) — which is
1313                    // what `ScrollArea` and every data view add straight to
1314                    // their scroll position. So a wheel-down notch arrives
1315                    // as `y > 0` and must *decrement*, matching every other
1316                    // stepper on the platform.
1317                    let dir = if y > 0.0 { -1 } else { 1 };
1318                    (step_for_wheel)(dir, false, ctx);
1319                    EventResponse::Handled
1320                }
1321            })
1322            .on_access_action(move |action, ctx| {
1323                use teksilo_core::accesskit::Action;
1324                match action {
1325                    Action::Increment => {
1326                        (step.clone())(1, false, ctx);
1327                        EventResponse::Handled
1328                    }
1329                    Action::Decrement => {
1330                        (step.clone())(-1, false, ctx);
1331                        EventResponse::Handled
1332                    }
1333                    Action::Focus => {
1334                        ctx.request_focus(field_id_for_access);
1335                        EventResponse::Handled
1336                    }
1337                    _ => EventResponse::Ignored,
1338                }
1339            });
1340        // Bind `value` so the SpinButton a11y node refreshes on
1341        // every change (numeric_value setter reads it live).
1342        let self_id = ctx.self_id();
1343        value_for_a11y.bind_to(
1344            self_id,
1345            ctx.binding_registry(),
1346            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1347        );
1348
1349        ctx.apply_self_handlers(handlers);
1350
1351        // ── Tooltip attachment ─────────────────────────────────────
1352        if let Some(content) = self.composite_tooltip_content.take() {
1353            let delay = ctx.theme().motion.tooltip_delay_heavy;
1354            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
1355        } else if let Some(source) = self.rich_tooltip_source.clone() {
1356            let delay = ctx.theme().motion.tooltip_delay;
1357            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
1358        } else if let Some(text) = self.tooltip_text.clone() {
1359            let delay = ctx.theme().motion.tooltip_delay;
1360            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
1361        }
1362
1363        vec![root_id]
1364    }
1365
1366    fn layout_response(
1367        &self,
1368        proposal: SizeProposal,
1369        ctx: &LayoutContext,
1370    ) -> teksilo_core::widget::LayoutResponse {
1371        // Narrow the parent's proposal by `pixel_cap` (if any)
1372        // before delegating. This enforces the `.width(...)` /
1373        // `.width_chars(...)` caps without wrapping the subtree
1374        // in a clipping `MaxSize` — the focus-state border stroke
1375        // can then extend 1 dp outside the visual bounds
1376        // (Int UI focus thickening) without being chopped off
1377        // against the shape quad.
1378        // `pixel_cap` / `min_width` are measured at 1.0 scale in `build()`. The
1379        // inner field grows its text by `ctx.text_scale`, so the cap must grow
1380        // too or the scaled digits clip against an un-grown width cap.
1381        let scale = ctx.text_scale;
1382        let pixel_cap = self.pixel_cap.map(|c| c * scale);
1383        let min_width = self.min_width * scale;
1384        let effective_proposal = SizeProposal {
1385            width: match (proposal.width, pixel_cap) {
1386                (Some(w), Some(cap)) => Some(w.min(cap).max(min_width)),
1387                (None, Some(cap)) => Some(cap.max(min_width)),
1388                (w, None) => w,
1389            },
1390            height: proposal.height,
1391        };
1392        let child_size = self
1393            .root_child_id
1394            .and_then(|id| ctx.child_size(id, effective_proposal))
1395            .unwrap_or_else(|| effective_proposal.resolve(0.0, 0.0));
1396        // Claim the (cap-narrowed) proposal width on the cross axis — the
1397        // inner `ZStack` queries its children with `SizeProposal::unspecified`,
1398        // so the offered width never reaches the inner `HStack` during
1399        // measurement; without this clamp the chain returns just
1400        // `MinSize`'s floor and the SpinBox collapses regardless of
1401        // `WidthPolicy::Fill` or `Pixels`/`Chars` caps.
1402        let w = match effective_proposal.width {
1403            Some(pw) => pw.max(child_size.width),
1404            None => child_size.width,
1405        };
1406        Size::new(w, child_size.height).into()
1407    }
1408
1409    fn place_children(
1410        &self,
1411        bounds: Rect,
1412        _proposal: SizeProposal,
1413        children: &mut [WidgetPlacement],
1414        _ctx: &LayoutContext,
1415    ) {
1416        if let Some(p) = children.first_mut() {
1417            p.origin = Point::new(bounds.x, bounds.y);
1418            p.size = bounds.size();
1419        }
1420    }
1421
1422    fn children(&self) -> Vec<WidgetId> {
1423        self.root_child_id.into_iter().collect()
1424    }
1425
1426    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1427        use teksilo_core::accesskit::{Action, Role};
1428
1429        builder.set_role(Role::SpinButton);
1430        if let Some(ref label) = self.label {
1431            builder.set_name(label.resolve_now());
1432        }
1433        builder.set_numeric_value(self.value.get().to_f64());
1434        builder.set_min_numeric_value(self.min.to_f64());
1435        builder.set_max_numeric_value(self.max.to_f64());
1436        builder.set_numeric_value_step(self.single_step.to_f64());
1437        if let Some(page) = self.page_step {
1438            builder.set_numeric_value_jump(page.to_f64());
1439        } else {
1440            builder.set_numeric_value_jump(self.single_step.saturating_mul_u32(10).to_f64());
1441        }
1442        // String-valued representation so screen readers can read
1443        // out the suffix / special-value text when applicable. The
1444        // suffix is elided when `special_value_text` has kicked in
1445        // (value == min), matching the visual rendering.
1446        let value = self.value.get();
1447        let using_special = self.special_value_text.is_some() && approx_eq(value, self.min);
1448        let display = format_for_display(
1449            value,
1450            self.decimals,
1451            self.special_value_text.as_ref(),
1452            self.text_from_value.as_deref(),
1453            self.min,
1454            false,
1455            &NumberPresentation::resolve(self.localized, self.use_grouping),
1456        );
1457        let full = if !self.suffix.is_empty() && !using_special {
1458            format!("{}{}", display, self.suffix)
1459        } else {
1460            display
1461        };
1462        builder.set_value(full);
1463
1464        // Framework a11y walker sets `set_disabled` from arena state.
1465        if self.read_only {
1466            builder.set_read_only();
1467        }
1468        builder.add_action(Action::Increment);
1469        builder.add_action(Action::Decrement);
1470        builder.add_action(Action::SetValue);
1471        builder.add_action(Action::Focus);
1472    }
1473}
1474
1475// ── Helpers ────────────────────────────────────────────────────────
1476
1477/// Build the stacked up/down step-button column. Returns its root
1478/// `WidgetId` for the caller to drop into the HStack.
1479///
1480/// `step` (with `EventContext`) is called on the initial tap;
1481/// `step_silent` is called from the hold-to-repeat timer, which
1482/// runs inside a frame-tick effect with no `EventContext` to hand.
1483fn build_step_buttons<T: SpinValue>(
1484    ctx: &mut BuildContext,
1485    step: &Rc<dyn Fn(i32, bool, &mut EventContext)>,
1486    step_silent: &Rc<dyn Fn(i32, bool) -> Option<T>>,
1487    can_up: Signal<bool>,
1488    can_down: Signal<bool>,
1489    enabled: bool,
1490    frame_height: f32,
1491    corner_radius: f32,
1492) -> (WidgetId, WidgetId) {
1493    // Each button is half of the field's inner height (minus the
1494    // borders and a 1 px gutter between the two).
1495    let button_height = ((frame_height - 2.0) * 0.5).max(8.0);
1496    let button_width = 18.0;
1497
1498    let up_icon = chevron_up_icon(8.0);
1499    let down_icon = chevron_down_icon(8.0);
1500
1501    // Derived enabled signals: OR with the caller-wide `enabled`.
1502    let up_enabled = if enabled { can_up } else { Signal::new(false) };
1503    let down_enabled = if enabled {
1504        can_down
1505    } else {
1506        Signal::new(false)
1507    };
1508
1509    let step_for_up_tap = step.clone();
1510    let silent_for_up_auto = step_silent.clone();
1511    let up_button = StepButton::new(up_icon, up_enabled, move |ctx| {
1512        (step_for_up_tap)(1, false, ctx);
1513    })
1514    .on_auto_repeat(move || {
1515        (silent_for_up_auto)(1, false);
1516    })
1517    .size(button_width, button_height)
1518    .corner_radius(CornerRadius {
1519        top_left: 0.0,
1520        top_right: corner_radius,
1521        bottom_left: 0.0,
1522        bottom_right: 0.0,
1523    });
1524
1525    let step_for_down_tap = step.clone();
1526    let silent_for_down_auto = step_silent.clone();
1527    let down_button = StepButton::new(down_icon, down_enabled, move |ctx| {
1528        (step_for_down_tap)(-1, false, ctx);
1529    })
1530    .on_auto_repeat(move || {
1531        (silent_for_down_auto)(-1, false);
1532    })
1533    .size(button_width, button_height)
1534    .corner_radius(CornerRadius {
1535        top_left: 0.0,
1536        top_right: 0.0,
1537        bottom_left: 0.0,
1538        bottom_right: corner_radius,
1539    });
1540
1541    (ctx.add(up_button), ctx.add(down_button))
1542}
1543
1544/// Small chevron-up icon at `size` px. Mirrors the shape of the
1545/// `chevron_down` icon provided by `IconWidget`.
1546fn chevron_up_icon(size: f32) -> IconWidget {
1547    let mut path = Path::new();
1548    let s = size;
1549    path.move_to(Point::new(s * 0.25, s * 0.65));
1550    path.line_to(Point::new(s * 0.5, s * 0.35));
1551    path.line_to(Point::new(s * 0.75, s * 0.65));
1552    IconWidget::from_path(path, size)
1553}
1554
1555fn chevron_down_icon(size: f32) -> IconWidget {
1556    let mut path = Path::new();
1557    let s = size;
1558    path.move_to(Point::new(s * 0.25, s * 0.35));
1559    path.line_to(Point::new(s * 0.5, s * 0.65));
1560    path.line_to(Point::new(s * 0.75, s * 0.35));
1561    IconWidget::from_path(path, size)
1562}
1563
1564/// How the number itself is rendered and read back: the locale's
1565/// conventions, or the C locale.
1566///
1567/// Resolved once per `build()` and threaded through the format and
1568/// parse paths together, so the two can never disagree about which
1569/// separator this field is using — the same single-source discipline
1570/// `DateEdit` gets from its one `ParsedPattern`.
1571#[derive(Clone)]
1572pub(crate) struct NumberPresentation {
1573    symbols: Option<Rc<teksilo_i18n::NumberSymbols>>,
1574    grouping: bool,
1575}
1576
1577impl NumberPresentation {
1578    /// Resolve against the active locale. `localized == false` yields a
1579    /// presentation that is the identity in both directions.
1580    pub(crate) fn resolve(localized: bool, grouping: bool) -> Self {
1581        Self {
1582            symbols: localized.then(teksilo_i18n::NumberSymbols::current),
1583            grouping,
1584        }
1585    }
1586
1587    /// C-locale digits in, display string out.
1588    fn render(&self, plain: String) -> String {
1589        match &self.symbols {
1590            Some(sym) => sym.localize(&plain, self.grouping),
1591            None => plain,
1592        }
1593    }
1594
1595    /// Display string in, C-locale digits out. `None` when the text
1596    /// cannot be a number in this locale.
1597    fn read(&self, raw: &str) -> Option<String> {
1598        match &self.symbols {
1599            Some(sym) => sym.delocalize(raw),
1600            None => Some(raw.trim().to_string()),
1601        }
1602    }
1603
1604    /// Parse user input into a value, going through the locale first.
1605    fn parse<T: SpinValue>(&self, raw: &str) -> Option<T> {
1606        T::parse(&self.read(raw)?)
1607    }
1608
1609    /// Per-character input filter. Widens the type's own filter with the
1610    /// characters this locale writes numbers with, so a French user can
1611    /// type `,` and an Egyptian user can type `٫` or Arabic-Indic
1612    /// digits — while the ASCII forms keep working everywhere, because
1613    /// people type on the keyboard they have.
1614    fn accepts_char<T: SpinValue>(&self, c: char) -> bool {
1615        if T::is_valid_input_char(c) {
1616            return true;
1617        }
1618        let Some(sym) = &self.symbols else {
1619            return false;
1620        };
1621        if sym.has_non_ascii_digits() && sym.delocalize(&c.to_string()).is_some() {
1622            return true;
1623        }
1624        // The group separator is only typeable when this field shows
1625        // groups; otherwise it is noise the user cannot have meant.
1626        [
1627            Some(sym.decimal_separator()),
1628            Some(sym.minus_sign()),
1629            Some(sym.plus_sign()),
1630            self.grouping.then(|| sym.group_separator()),
1631        ]
1632        .into_iter()
1633        .flatten()
1634        .any(|sep| sep.chars().any(|sc| sc == c))
1635    }
1636}
1637
1638/// Format `value` for display, honoring `special_value_text` when
1639/// applicable and deferring to a user-supplied formatter when set.
1640///
1641/// `force_plain` bypasses `special_value_text` even when the value
1642/// equals `min` — used when the field is focused so the user can
1643/// edit the number instead of a placeholder string.
1644///
1645/// A user-supplied `custom` formatter owns the whole string and is
1646/// **not** localized afterwards: it already returns exactly what the
1647/// caller wants shown, and re-punctuating it would corrupt formats the
1648/// caller composed deliberately.
1649fn format_for_display<T: SpinValue>(
1650    value: T,
1651    decimals: u8,
1652    special: Option<&LocalizedString>,
1653    custom: Option<&dyn Fn(T) -> LocalizedString>,
1654    min: T,
1655    force_plain: bool,
1656    presentation: &NumberPresentation,
1657) -> String {
1658    if !force_plain
1659        && let Some(special_text) = special
1660        && approx_eq(value, min)
1661    {
1662        return special_text.resolve_now();
1663    }
1664    match custom {
1665        Some(f) => f(value).resolve_now(),
1666        None => presentation.render(value.format(decimals)),
1667    }
1668}
1669
1670/// Decide the effective step for an [`Adaptive`](StepType::Adaptive)
1671/// step type given the current value. For a value ∈ [10^n,
1672/// 10^(n+1)) the effective step is 10^n; inside [0, 1) the
1673/// step stays at `base_step` to avoid vanishing.
1674fn resolve_effective_step<T: SpinValue>(step_type: StepType, current: T, base_step: T) -> T {
1675    if step_type == StepType::Fixed {
1676        return base_step;
1677    }
1678    let abs = current.to_f64().abs();
1679    if abs < 10.0 {
1680        return base_step;
1681    }
1682    let pow = abs.log10().floor();
1683    let magnitude = 10f64.powf(pow);
1684    let adaptive = T::from_f64_saturating(magnitude);
1685    // Fall back to the user's base step if adaptive truncates to
1686    // zero (possible for integer types when pow < 0).
1687    let adaptive_f = adaptive.to_f64();
1688    if adaptive_f.abs() < 1e-12 {
1689        base_step
1690    } else {
1691        adaptive
1692    }
1693}
1694
1695/// Approximate equality. Integer types compare bit-exactly;
1696/// floats tolerate sub-unit-in-last-place jitter. Used throughout
1697/// to suppress redundant signal sets.
1698fn approx_eq<T: SpinValue>(a: T, b: T) -> bool {
1699    if T::is_integer() {
1700        a.to_f64() == b.to_f64()
1701    } else {
1702        // Relative epsilon scaled by value magnitude so both near-zero
1703        // and large-value comparisons behave.
1704        let af = a.to_f64();
1705        let bf = b.to_f64();
1706        let scale = af.abs().max(bf.abs()).max(1.0);
1707        (af - bf).abs() <= scale * 1e-9
1708    }
1709}
1710
1711fn approx_ne<T: SpinValue>(a: T, b: T) -> bool {
1712    !approx_eq(a, b)
1713}
1714
1715/// Measure the advance width of `text` in logical pixels using the
1716/// app-wide `SharedTypesetter` (the same backend the field paints
1717/// with). Falls back to a rough heuristic when no typesetter is
1718/// installed (headless tests) so the caller still gets a non-zero
1719/// width and the `MaxSize` cap behaves reasonably.
1720fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
1721    if text.is_empty() {
1722        return 0.0;
1723    }
1724    if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
1725        let backend = ts.as_text_backend();
1726        let layout = backend.borrow_mut().layout_single_line(text, style, None);
1727        return layout.width;
1728    }
1729    // Headless fallback: ~0.55 × font size per ASCII char is a
1730    // close approximation for Inter Regular at body weight.
1731    text.chars().count() as f32 * style.size * 0.55
1732}