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}