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}