Skip to main content

damascene_core/widgets/
numeric_input.rs

1//! Numeric input — text input with stepper buttons.
2//!
3//! Two visual variants share one event surface:
4//!
5//! - **Flanked** (default) — `[−] [text] [+]`. Hit area-friendly,
6//!   matches the existing widget shape.
7//! - **Stacked** — `[text │ ⌃/⌄]`. The conventional `<input type="number">`
8//!   look (Tailwind UI, browser native). Opt in via [`NumericInputOpts::stacked`].
9//!
10//! shadcn doesn't ship a dedicated component (web apps lean on
11//! `<input type="number">` and let the browser draw spinners); for a
12//! renderer-agnostic UI kit we render the spinners explicitly so the
13//! affordance is consistent across backends.
14//!
15//! The app owns the value as a `String` (matching [`crate::widgets::text_input`]) so
16//! mid-edit states like `"1."` aren't clobbered by a parse-and-reformat
17//! round-trip on every keystroke. Parse to a number with
18//! `s.parse::<f64>()` (or `i64`, …) when you actually need the value.
19//!
20//! ```ignore
21//! use damascene_core::prelude::*;
22//!
23//! struct Form {
24//!     count: String,
25//!     selection: Selection,
26//! }
27//!
28//! impl App for Form {
29//!     fn build(&self, _cx: &BuildCx) -> El {
30//!         let opts = NumericInputOpts::default()
31//!             .min(0.0)
32//!             .max(100.0)
33//!             .step(1.0);
34//!         numeric_input(&self.count, &self.selection, "count", opts)
35//!     }
36//!
37//!     fn on_event(&mut self, e: UiEvent) {
38//!         let opts = NumericInputOpts::default()
39//!             .min(0.0)
40//!             .max(100.0)
41//!             .step(1.0);
42//!         numeric_input::apply_event(
43//!             &mut self.count, &mut self.selection, "count", &opts, &e,
44//!         );
45//!     }
46//! }
47//! ```
48//!
49//! # Routed keys
50//!
51//! - `{key}:dec` — `Click` on the down/`−` button. Steps the value down.
52//! - `{key}:inc` — `Click` on the up/`+` button. Steps the value up.
53//! - `{key}:field` — the inner [`crate::widgets::text_input`]; routed text edits / IME
54//!   commits / pointer caret moves all flow through this key.
55//!   `ArrowUp` / `ArrowDown` `KeyDown` events routed to this key are
56//!   intercepted as step actions (the keyboard counterpart to the
57//!   spinner buttons).
58//!
59//! Spinner clicks parse the current `value`, add or subtract
60//! `opts.step`, clamp to `opts.min`/`opts.max` if set, and write the
61//! formatted result back. If the value can't be parsed (empty or
62//! garbage), the spinner treats it as `min` when set, otherwise as
63//! `0.0`.
64//!
65//! # Modifier-scaled steps
66//!
67//! Spinner clicks and arrow-key steps both honor modifier keys to
68//! produce coarse / fine adjustments without changing `opts.step`:
69//!
70//! - **Shift** — multiplies the step by 10 (coarse).
71//! - **Alt** — multiplies the step by 0.1 (fine; rounded to
72//!   `opts.decimals` when set).
73//!
74//! Holding both at once falls back to `Shift` since coarse is the more
75//! common power-user gesture.
76//!
77//! # Dogfood note
78//!
79//! Composes only the public widget-kit surface: a `row` of ghost
80//! [`button`]s / [`icon_button`]s and an inner [`text_input_with`].
81//! An app crate can fork this file to add a different spinner shape
82//! (wheel-on-scroll, named units, …) without touching library
83//! internals.
84
85use std::panic::Location;
86
87use crate::event::{KeyModifiers, UiEvent, UiEventKind, UiKey};
88use crate::selection::Selection;
89use crate::tokens;
90use crate::tree::*;
91use crate::widgets::button::{button, icon_button};
92use crate::widgets::text_input::{
93    TextInputOpts, apply_event_with as text_input_apply, text_input_with,
94};
95
96/// Configuration for [`numeric_input`] / [`apply_event`].
97///
98/// Defaults: no min, no max, `step = 1.0`, no fixed precision, no
99/// placeholder. The same value is expected to be available both at
100/// build-time (for the placeholder) and at event-time (so spinner
101/// clicks know how much to step and where to clamp), so this is a
102/// struct the app holds onto rather than chained modifiers on the
103/// returned `El` — the same pattern [`TextInputOpts`] uses.
104#[derive(Clone, Copy, Debug)]
105pub struct NumericInputOpts<'a> {
106    /// Lower bound. Spinner clicks clamp to at least this value.
107    /// `None` means unbounded below.
108    pub min: Option<f64>,
109    /// Upper bound. Spinner clicks clamp to at most this value.
110    /// `None` means unbounded above.
111    pub max: Option<f64>,
112    /// Increment for one spinner click. Default `1.0`.
113    pub step: f64,
114    /// Fixed decimal places for the formatted result.
115    /// `None` means: integral values render as `42`, non-integral via
116    /// `f64::Display`. `Some(n)` always formats with `n` decimals
117    /// (e.g. `Some(2)` produces `"3.50"`).
118    pub decimals: Option<u8>,
119    /// Muted hint shown only while `value` is empty.
120    pub placeholder: Option<&'a str>,
121    /// Render the steppers as a stacked `⌃` / `⌄` column on the right
122    /// edge of the field — the conventional `<input type="number">`
123    /// shape — instead of `−` / `+` buttons flanking the field.
124    ///
125    /// Routed keys (`{key}:inc`, `{key}:dec`, `{key}:field`) are the
126    /// same in both layouts, so [`apply_event`] doesn't branch.
127    pub stacked: bool,
128}
129
130impl Default for NumericInputOpts<'_> {
131    fn default() -> Self {
132        Self {
133            min: None,
134            max: None,
135            step: 1.0,
136            decimals: None,
137            placeholder: None,
138            stacked: false,
139        }
140    }
141}
142
143impl<'a> NumericInputOpts<'a> {
144    pub fn min(mut self, v: f64) -> Self {
145        self.min = Some(v);
146        self
147    }
148    pub fn max(mut self, v: f64) -> Self {
149        self.max = Some(v);
150        self
151    }
152    pub fn step(mut self, v: f64) -> Self {
153        self.step = v;
154        self
155    }
156    pub fn decimals(mut self, v: u8) -> Self {
157        self.decimals = Some(v);
158        self
159    }
160    pub fn placeholder(mut self, p: &'a str) -> Self {
161        self.placeholder = Some(p);
162        self
163    }
164    /// Opt into the stacked-chevron variant. Equivalent to
165    /// `NumericInputOpts { stacked: true, ..self }`.
166    pub fn stacked(mut self) -> Self {
167        self.stacked = true;
168        self
169    }
170}
171
172/// A numeric input field. Defaults to the flanked layout
173/// `[−] [text_input] [+]`; opt into the stacked-chevron variant with
174/// [`NumericInputOpts::stacked`].
175///
176/// The two spinner buttons are routed `{key}:dec` and `{key}:inc` in
177/// both layouts; the inner text input is keyed `{key}:field`. The
178/// wrapping `row` is keyed `{key}` itself so layout/test code can find
179/// the whole composite by the same name the app uses.
180#[track_caller]
181pub fn numeric_input(
182    value: &str,
183    selection: &Selection,
184    key: &str,
185    opts: NumericInputOpts<'_>,
186) -> El {
187    let caller = Location::caller();
188
189    let mut text_opts = TextInputOpts::default();
190    if let Some(p) = opts.placeholder {
191        text_opts = text_opts.placeholder(p);
192    }
193    let field_key = format!("{key}:field");
194    let field = text_input_with(value, selection, &field_key, text_opts).width(Size::Fill(1.0));
195
196    // RING_WIDTH gap: each focusable child needs a sliver of space so
197    // its focus-ring band isn't painted over by the next sibling.
198    //
199    // The wrapping row defaults to a fixed width ([`DEFAULT_WIDTH`])
200    // and the inner field stays `Fill(1.0)` to claim whatever's left
201    // after the spinner buttons / chevron column. This avoids two
202    // failure modes: a `Hug` row would collapse the inner `Fill(1.0)`
203    // field to zero, and a `Fill(1.0)` row would stretch a 3-digit
204    // value across the entire form — see [`DEFAULT_WIDTH`] for the
205    // design rationale.
206    let children: Vec<El> = if opts.stacked {
207        vec![field, stacked_chevron_column(key, caller)]
208    } else {
209        let dec = button("−")
210            .at_loc(caller)
211            .key(format!("{key}:dec"))
212            .ghost()
213            .width(Size::Fixed(tokens::CONTROL_HEIGHT))
214            .height(Size::Fixed(tokens::CONTROL_HEIGHT));
215        let inc = button("+")
216            .at_loc(caller)
217            .key(format!("{key}:inc"))
218            .ghost()
219            .width(Size::Fixed(tokens::CONTROL_HEIGHT))
220            .height(Size::Fixed(tokens::CONTROL_HEIGHT));
221        vec![dec, field, inc]
222    };
223
224    row(children)
225        .at_loc(caller)
226        .key(key.to_string())
227        .gap(tokens::RING_WIDTH)
228        .align(Align::Center)
229        .default_width(Size::Fixed(DEFAULT_WIDTH))
230        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
231}
232
233/// Width of the stacked-chevron column. Narrow enough to feel like an
234/// edge affordance, wide enough for a 14px chevron to sit centered
235/// with a touch of horizontal breathing room.
236const STACKED_CHEVRON_WIDTH: f32 = 22.0;
237
238/// Default width of the wrapping row. Comfortable for 3–4 digit values
239/// in either layout — equivalent to Tailwind's `w-36`.
240///
241/// Numeric inputs intrinsically display short values, so the default
242/// is a fixed width rather than filling the parent — apps that want
243/// the wider, text-input-style fill explicitly chain
244/// `.width(Size::Fill(1.0))` on the returned `El`. This mirrors the
245/// design-system consensus for numeric inputs (Material UI's
246/// `<TextField type="number">` defaults `fullWidth=false`; Chakra's
247/// `<NumberInput>` is content-width; Tailwind UI's examples use
248/// `w-24` / `w-32`) rather than shadcn's generic-`<Input>` `w-full`
249/// default that lumps numeric in with free-text fields.
250pub const DEFAULT_WIDTH: f32 = 144.0;
251
252/// Build the `⌃` over `⌄` chevron stack used by the stacked variant.
253/// Each chevron is its own focusable [`icon_button`] so the inc/dec
254/// hit areas remain distinct (and stay reachable by Tab focus).
255///
256/// Focus rings render inside each button's rect (via
257/// [`El::focus_ring_inside`]) rather than the default outward bleed.
258/// Without this, the up chevron's bottom focus-ring band would be
259/// occluded by the dec button painted immediately below — the same
260/// idiom dropdown-menu rows and calendar days use for densely packed
261/// focusables that should stay visually flush. Each chevron is exactly
262/// `CONTROL_HEIGHT / 2` so the two split the column with no gap.
263fn stacked_chevron_column(key: &str, caller: &'static Location<'static>) -> El {
264    let half_h = (tokens::CONTROL_HEIGHT * 0.5).floor();
265    let inc = icon_button("chevron-up")
266        .at_loc(caller)
267        .key(format!("{key}:inc"))
268        .ghost()
269        .icon_size(tokens::ICON_XS)
270        .focus_ring_inside()
271        .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
272        .height(Size::Fixed(half_h));
273    let dec = icon_button("chevron-down")
274        .at_loc(caller)
275        .key(format!("{key}:dec"))
276        .ghost()
277        .icon_size(tokens::ICON_XS)
278        .focus_ring_inside()
279        .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
280        .height(Size::Fixed(half_h));
281    column([inc, dec])
282        .at_loc(caller)
283        .gap(0.0)
284        .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
285        .height(Size::Fixed(tokens::CONTROL_HEIGHT))
286}
287
288/// Fold a routed [`UiEvent`] into the numeric input's value, handling
289/// spinner clicks, arrow-key steps on the focused field, and text
290/// edits. Returns `true` if the event belonged to this widget
291/// (regardless of whether the value changed).
292///
293/// Spinner clicks and arrow-key steps parse the current `value`, step
294/// by `opts.step` (scaled by `Shift`/`Alt` modifiers), clamp to
295/// `opts.min`/`opts.max`, and rewrite `value` formatted per
296/// `opts.decimals`. Text edits are forwarded verbatim to
297/// [`crate::widgets::text_input::apply_event`] — no parse / reformat cycle, so a
298/// half-typed `"1."` keeps its cursor position.
299pub fn apply_event(
300    value: &mut String,
301    selection: &mut Selection,
302    key: &str,
303    opts: &NumericInputOpts<'_>,
304    event: &UiEvent,
305) -> bool {
306    if matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
307        let inc_key = format!("{key}:inc");
308        let dec_key = format!("{key}:dec");
309        if event.route() == Some(inc_key.as_str()) {
310            step_value(value, opts, 1, event.modifiers);
311            return true;
312        }
313        if event.route() == Some(dec_key.as_str()) {
314            step_value(value, opts, -1, event.modifiers);
315            return true;
316        }
317    }
318
319    let field_key = format!("{key}:field");
320
321    // Arrow up / down on the focused field step the value — the
322    // keyboard counterpart to the spinner buttons. text_input's own
323    // KeyDown handler ignores ArrowUp/Down (it only consumes
324    // ArrowLeft/Right/Home/End), so intercepting here doesn't steal
325    // caret moves.
326    if event.kind == UiEventKind::KeyDown
327        && event.is_route(&field_key)
328        && let Some(kp) = event.key_press.as_ref()
329    {
330        let dir = match kp.key {
331            UiKey::ArrowUp => Some(1),
332            UiKey::ArrowDown => Some(-1),
333            _ => None,
334        };
335        if let Some(d) = dir {
336            step_value(value, opts, d, kp.modifiers);
337            return true;
338        }
339    }
340
341    // Only consume text events that actually target the inner field.
342    // text_input::apply_event itself doesn't gate on target_key
343    // (callers do, see the per-input dispatch in the Inputs section);
344    // forwarding every event would steal keystrokes meant for sibling
345    // widgets and dump them into our value.
346    if event.target_key() != Some(field_key.as_str()) {
347        return false;
348    }
349
350    let text_opts = match opts.placeholder {
351        Some(p) => TextInputOpts::default().placeholder(p),
352        None => TextInputOpts::default(),
353    };
354
355    // Run the text_input edit, then revert if the post-edit value
356    // contains non-numeric characters. The filter is permissive: any
357    // char in `[0-9.eE+\-]` is allowed so mid-edit states like `"-"`,
358    // `"1."`, or `"1.5e+"` keep the cursor where the user expects
359    // while the value isn't yet a complete f64.
360    let prev_value = value.clone();
361    let prev_selection = selection.clone();
362    let changed = text_input_apply(value, selection, &field_key, event, &text_opts);
363    if changed && !is_acceptable_numeric_progress(value) {
364        *value = prev_value;
365        *selection = prev_selection;
366        return false;
367    }
368    changed
369}
370
371fn is_acceptable_numeric_progress(s: &str) -> bool {
372    s.is_empty()
373        || s.chars()
374            .all(|c| matches!(c, '0'..='9' | '.' | 'e' | 'E' | '+' | '-'))
375}
376
377fn step_value(value: &mut String, opts: &NumericInputOpts<'_>, dir: i32, mods: KeyModifiers) {
378    // Treat unparseable input as `min` if set, else 0 — same shape as
379    // browsers' default for `<input type="number">` arrow clicks
380    // against an empty field.
381    let parsed = value
382        .parse::<f64>()
383        .ok()
384        .unwrap_or_else(|| opts.min.unwrap_or(0.0));
385    let stepped = parsed + (dir as f64) * opts.step * step_scale(mods);
386    let clamped = clamp_opt(stepped, opts.min, opts.max);
387    *value = format_numeric(clamped, opts.decimals);
388}
389
390/// Modifier-key step multiplier. `Shift` → 10× (coarse), `Alt` → 0.1×
391/// (fine). When both are held, prefer `Shift` since coarse is the
392/// dominant power-user gesture and the simultaneous combo is rarely
393/// pressed intentionally.
394fn step_scale(mods: KeyModifiers) -> f64 {
395    if mods.shift {
396        10.0
397    } else if mods.alt {
398        0.1
399    } else {
400        1.0
401    }
402}
403
404fn clamp_opt(n: f64, min: Option<f64>, max: Option<f64>) -> f64 {
405    let n = if let Some(hi) = max { n.min(hi) } else { n };
406    if let Some(lo) = min { n.max(lo) } else { n }
407}
408
409fn format_numeric(n: f64, decimals: Option<u8>) -> String {
410    match decimals {
411        Some(d) => format!("{:.*}", d as usize, n),
412        None if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e18 => {
413            // Integral: render without trailing ".0" so the canonical
414            // round-trip of `numeric_input("0", ...) → click + → "1"`
415            // doesn't drift to "1.0".
416            format!("{}", n as i64)
417        }
418        None => format!("{n}"),
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::event::{KeyModifiers, UiTarget};
426    use crate::layout::layout;
427    use crate::state::UiState;
428    use crate::tree::Rect;
429
430    fn click(key: &str) -> UiEvent {
431        UiEvent::synthetic_click(key)
432    }
433
434    #[test]
435    fn default_is_fixed_width_with_inner_field_filling() {
436        // Two regressions, one test: a numeric input dropped into a
437        // wide Fill parent must (a) take its declared fixed width
438        // ([`DEFAULT_WIDTH`]) rather than stretching across the row,
439        // and (b) the inner `Fill(1.0)` text field must still claim
440        // the leftover space inside that fixed wrapper — earlier
441        // iterations either filled the whole parent or collapsed the
442        // field to zero.
443        let value = String::from("42");
444        let sel = Selection::default();
445        let widget = numeric_input(&value, &sel, "n", NumericInputOpts::default());
446        let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
447            crate::widgets::form::form_control(widget),
448        ])]);
449        let mut state = UiState::new();
450        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
451
452        let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
453        let field_rect = state.rect_of_key(&tree, "n:field").expect("field rect");
454        assert_eq!(
455            row_rect.w, DEFAULT_WIDTH,
456            "row should keep its fixed default width inside a wide form parent"
457        );
458        // Inner field fills the leftover space after the two spinner
459        // buttons plus inter-child ring gaps.
460        let expected_field_w =
461            DEFAULT_WIDTH - 2.0 * tokens::CONTROL_HEIGHT - 2.0 * tokens::RING_WIDTH;
462        assert!(
463            (field_rect.w - expected_field_w).abs() < 0.5,
464            "field should take leftover space inside wrapper, got {} expected ~{}",
465            field_rect.w,
466            expected_field_w,
467        );
468    }
469
470    #[test]
471    fn explicit_width_fill_still_works() {
472        // The fixed default is a hint, not a hard cap — apps that want
473        // the wider text-input-style behavior chain `.width(...)` and
474        // get it. `default_width` is preempted by an explicit `width`.
475        let value = String::from("42");
476        let sel = Selection::default();
477        let widget =
478            numeric_input(&value, &sel, "n", NumericInputOpts::default()).width(Size::Fill(1.0));
479        let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
480            crate::widgets::form::form_control(widget),
481        ])]);
482        let mut state = UiState::new();
483        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
484        let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
485        assert!(
486            row_rect.w > DEFAULT_WIDTH,
487            "explicit `.width(Fill)` should override the fixed default, got {}",
488            row_rect.w,
489        );
490    }
491
492    /// Build a TextInput event targeting `target_key` with `text` as
493    /// the composed payload. Used to drive both the routing-gate and
494    /// the numeric-character-filter tests.
495    fn text_event(target_key: &str, text: &str) -> UiEvent {
496        UiEvent {
497            path: None,
498            key: Some(target_key.to_string()),
499            target: Some(UiTarget {
500                key: target_key.to_string(),
501                node_id: format!("/{target_key}"),
502                rect: Rect::new(0.0, 0.0, 100.0, 32.0),
503                tooltip: None,
504                scroll_offset_y: 0.0,
505            }),
506            pointer: None,
507            key_press: None,
508            text: Some(text.to_string()),
509            selection: None,
510            modifiers: KeyModifiers::default(),
511            click_count: 0,
512            pointer_kind: None,
513            wheel_delta: None,
514            kind: UiEventKind::TextInput,
515        }
516    }
517
518    #[test]
519    fn inc_steps_value_up_by_step() {
520        let mut value = String::from("3");
521        let mut sel = Selection::default();
522        let opts = NumericInputOpts::default().step(2.0);
523        assert!(apply_event(
524            &mut value,
525            &mut sel,
526            "n",
527            &opts,
528            &click("n:inc")
529        ));
530        assert_eq!(value, "5");
531    }
532
533    #[test]
534    fn dec_steps_value_down_by_step() {
535        let mut value = String::from("3");
536        let mut sel = Selection::default();
537        let opts = NumericInputOpts::default().step(0.5).decimals(1);
538        assert!(apply_event(
539            &mut value,
540            &mut sel,
541            "n",
542            &opts,
543            &click("n:dec")
544        ));
545        assert_eq!(value, "2.5");
546    }
547
548    #[test]
549    fn inc_clamps_to_max() {
550        let mut value = String::from("99");
551        let mut sel = Selection::default();
552        let opts = NumericInputOpts::default().min(0.0).max(100.0);
553        // 99 + 1*5 = 104, clamped to 100.
554        let opts = opts.step(5.0);
555        assert!(apply_event(
556            &mut value,
557            &mut sel,
558            "n",
559            &opts,
560            &click("n:inc")
561        ));
562        assert_eq!(value, "100");
563    }
564
565    #[test]
566    fn dec_clamps_to_min() {
567        let mut value = String::from("1");
568        let mut sel = Selection::default();
569        let opts = NumericInputOpts::default().min(0.0).max(100.0);
570        assert!(apply_event(
571            &mut value,
572            &mut sel,
573            "n",
574            &opts,
575            &click("n:dec")
576        ));
577        assert_eq!(value, "0");
578        // Already at min — another dec stays at 0.
579        assert!(apply_event(
580            &mut value,
581            &mut sel,
582            "n",
583            &opts,
584            &click("n:dec")
585        ));
586        assert_eq!(value, "0");
587    }
588
589    #[test]
590    fn empty_value_treated_as_min_when_set() {
591        let mut value = String::new();
592        let mut sel = Selection::default();
593        let opts = NumericInputOpts::default().min(10.0).max(100.0);
594        // Empty → starts at min (10), then +1 → 11.
595        assert!(apply_event(
596            &mut value,
597            &mut sel,
598            "n",
599            &opts,
600            &click("n:inc")
601        ));
602        assert_eq!(value, "11");
603    }
604
605    #[test]
606    fn empty_value_treated_as_zero_when_no_min() {
607        let mut value = String::new();
608        let mut sel = Selection::default();
609        let opts = NumericInputOpts::default();
610        assert!(apply_event(
611            &mut value,
612            &mut sel,
613            "n",
614            &opts,
615            &click("n:inc")
616        ));
617        assert_eq!(value, "1");
618    }
619
620    #[test]
621    fn unparseable_value_treated_as_zero_when_no_min() {
622        let mut value = String::from("abc");
623        let mut sel = Selection::default();
624        let opts = NumericInputOpts::default();
625        assert!(apply_event(
626            &mut value,
627            &mut sel,
628            "n",
629            &opts,
630            &click("n:inc")
631        ));
632        assert_eq!(value, "1");
633    }
634
635    #[test]
636    fn ignores_unrelated_keys() {
637        let mut value = String::from("3");
638        let mut sel = Selection::default();
639        let opts = NumericInputOpts::default();
640        // Different key family — should not match this widget.
641        assert!(!apply_event(
642            &mut value,
643            &mut sel,
644            "n",
645            &opts,
646            &click("other:inc")
647        ));
648        assert_eq!(value, "3");
649    }
650
651    #[test]
652    fn decimals_format_pads_zeros() {
653        let mut value = String::from("0");
654        let mut sel = Selection::default();
655        let opts = NumericInputOpts::default().step(0.10).decimals(2);
656        assert!(apply_event(
657            &mut value,
658            &mut sel,
659            "n",
660            &opts,
661            &click("n:inc")
662        ));
663        assert_eq!(value, "0.10");
664    }
665
666    #[test]
667    fn no_decimals_strips_trailing_zero() {
668        let mut value = String::from("0");
669        let mut sel = Selection::default();
670        let opts = NumericInputOpts::default().step(1.0);
671        assert!(apply_event(
672            &mut value,
673            &mut sel,
674            "n",
675            &opts,
676            &click("n:inc")
677        ));
678        // 1.0 → "1", not "1.0" (we only fall through to `f64::Display`
679        // when the result has a fractional component).
680        assert_eq!(value, "1");
681    }
682
683    #[test]
684    fn text_event_for_other_widget_is_ignored() {
685        // Regression: previously `apply_event` forwarded every
686        // non-spinner event into `text_input::apply_event`, which
687        // doesn't gate on target_key — so typing into a sibling
688        // text input would also write into the numeric input.
689        let mut value = String::from("42");
690        let mut sel = Selection::default();
691        let opts = NumericInputOpts::default();
692        // A TextInput event targeted at a sibling widget should not
693        // touch our value at all.
694        assert!(!apply_event(
695            &mut value,
696            &mut sel,
697            "n",
698            &opts,
699            &text_event("other-input", "x"),
700        ));
701        assert_eq!(value, "42");
702    }
703
704    #[test]
705    fn text_event_filter_rejects_non_numeric_chars() {
706        // A TextInput event targeting our inner field whose payload
707        // isn't numeric is rolled back so the value never absorbs
708        // letters / punctuation.
709        let mut value = String::from("12");
710        let mut sel = Selection::default();
711        let opts = NumericInputOpts::default();
712        assert!(!apply_event(
713            &mut value,
714            &mut sel,
715            "n",
716            &opts,
717            &text_event("n:field", "abc"),
718        ));
719        assert_eq!(value, "12");
720    }
721
722    #[test]
723    fn text_event_filter_accepts_partial_numeric_states() {
724        // Mid-edit values are kept: bare `-`, trailing `.`, exponent
725        // prefix, etc. should all pass the filter even though they
726        // aren't yet a complete f64.
727        for partial in ["-", "1.", "1.5e", "1.5e+", ".5", "+"] {
728            let mut value = String::new();
729            let mut sel = Selection::default();
730            let opts = NumericInputOpts::default();
731            assert!(
732                apply_event(
733                    &mut value,
734                    &mut sel,
735                    "n",
736                    &opts,
737                    &text_event("n:field", partial),
738                ),
739                "filter should accept partial value {partial:?}",
740            );
741            assert_eq!(value, partial, "value should equal {partial:?}");
742        }
743    }
744
745    #[test]
746    fn text_event_filter_accepts_full_numeric_paste() {
747        let mut value = String::new();
748        let mut sel = Selection::default();
749        let opts = NumericInputOpts::default();
750        assert!(apply_event(
751            &mut value,
752            &mut sel,
753            "n",
754            &opts,
755            &text_event("n:field", "42.5"),
756        ));
757        assert_eq!(value, "42.5");
758    }
759
760    #[test]
761    fn build_widget_has_three_children_and_correct_keys() {
762        let value = String::from("0");
763        let sel = Selection::default();
764        let opts = NumericInputOpts::default();
765        let el = numeric_input(&value, &sel, "n", opts);
766        assert_eq!(el.key.as_deref(), Some("n"));
767        assert_eq!(el.children.len(), 3, "decrement, field, increment");
768        assert_eq!(el.children[0].key.as_deref(), Some("n:dec"));
769        assert_eq!(el.children[1].key.as_deref(), Some("n:field"));
770        assert_eq!(el.children[2].key.as_deref(), Some("n:inc"));
771    }
772
773    /// Build a `KeyDown` event routed to `key` for the given physical
774    /// key + modifier mask. Used by the arrow-step and Shift/Alt
775    /// scaling tests.
776    fn key_event(key: &str, ui_key: UiKey, modifiers: KeyModifiers) -> UiEvent {
777        use crate::event::KeyPress;
778        UiEvent {
779            path: None,
780            key: Some(key.to_string()),
781            target: Some(UiTarget {
782                key: key.to_string(),
783                node_id: format!("/{key}"),
784                rect: Rect::new(0.0, 0.0, 100.0, 32.0),
785                tooltip: None,
786                scroll_offset_y: 0.0,
787            }),
788            pointer: None,
789            key_press: Some(KeyPress {
790                key: ui_key,
791                modifiers,
792                repeat: false,
793            }),
794            text: None,
795            selection: None,
796            modifiers,
797            click_count: 0,
798            pointer_kind: None,
799            wheel_delta: None,
800            kind: UiEventKind::KeyDown,
801        }
802    }
803
804    #[test]
805    fn arrow_up_on_field_steps_up() {
806        let mut value = String::from("3");
807        let mut sel = Selection::default();
808        let opts = NumericInputOpts::default().step(1.0);
809        assert!(apply_event(
810            &mut value,
811            &mut sel,
812            "n",
813            &opts,
814            &key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
815        ));
816        assert_eq!(value, "4");
817    }
818
819    #[test]
820    fn arrow_down_on_field_steps_down() {
821        let mut value = String::from("3");
822        let mut sel = Selection::default();
823        let opts = NumericInputOpts::default().step(1.0);
824        assert!(apply_event(
825            &mut value,
826            &mut sel,
827            "n",
828            &opts,
829            &key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
830        ));
831        assert_eq!(value, "2");
832    }
833
834    #[test]
835    fn shift_arrow_steps_by_ten_times() {
836        let mut value = String::from("3");
837        let mut sel = Selection::default();
838        let opts = NumericInputOpts::default().step(1.0);
839        let shift = KeyModifiers {
840            shift: true,
841            ..KeyModifiers::default()
842        };
843        assert!(apply_event(
844            &mut value,
845            &mut sel,
846            "n",
847            &opts,
848            &key_event("n:field", UiKey::ArrowUp, shift),
849        ));
850        assert_eq!(value, "13");
851    }
852
853    #[test]
854    fn alt_arrow_steps_by_one_tenth() {
855        // 0.1 step × 0.1 modifier = 0.01; with `.decimals(2)` the
856        // formatter pads to "0.01" instead of f64::Display's "0.01".
857        let mut value = String::from("0");
858        let mut sel = Selection::default();
859        let opts = NumericInputOpts::default().step(0.1).decimals(2);
860        let alt = KeyModifiers {
861            alt: true,
862            ..KeyModifiers::default()
863        };
864        assert!(apply_event(
865            &mut value,
866            &mut sel,
867            "n",
868            &opts,
869            &key_event("n:field", UiKey::ArrowUp, alt),
870        ));
871        assert_eq!(value, "0.01");
872    }
873
874    #[test]
875    fn shift_click_on_inc_button_scales_step() {
876        // Click events also honor the modifier mask, so Shift-clicking
877        // the `+` button is the pointer counterpart of Shift+ArrowUp.
878        let mut value = String::from("3");
879        let mut sel = Selection::default();
880        let opts = NumericInputOpts::default().step(1.0);
881        let mut ev = click("n:inc");
882        ev.modifiers = KeyModifiers {
883            shift: true,
884            ..KeyModifiers::default()
885        };
886        assert!(apply_event(&mut value, &mut sel, "n", &opts, &ev));
887        assert_eq!(value, "13");
888    }
889
890    #[test]
891    fn arrow_key_on_field_clamps_to_max() {
892        let mut value = String::from("99");
893        let mut sel = Selection::default();
894        let opts = NumericInputOpts::default().step(5.0).max(100.0);
895        assert!(apply_event(
896            &mut value,
897            &mut sel,
898            "n",
899            &opts,
900            &key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
901        ));
902        assert_eq!(value, "100");
903    }
904
905    #[test]
906    fn arrow_key_routed_elsewhere_is_ignored() {
907        // Arrow keys routed to a different widget mustn't move this
908        // numeric input's value — the keyboard handler is strictly
909        // gated on `{key}:field` route.
910        let mut value = String::from("3");
911        let mut sel = Selection::default();
912        let opts = NumericInputOpts::default();
913        assert!(!apply_event(
914            &mut value,
915            &mut sel,
916            "n",
917            &opts,
918            &key_event("other:field", UiKey::ArrowUp, KeyModifiers::default()),
919        ));
920        assert_eq!(value, "3");
921    }
922
923    #[test]
924    fn non_arrow_keydown_on_field_falls_through() {
925        // Letters, digits, Enter etc. arrive as TextInput events; an
926        // unrelated KeyDown (e.g. Tab) is not consumed by the numeric
927        // input so focus traversal still works.
928        let mut value = String::from("3");
929        let mut sel = Selection::default();
930        let opts = NumericInputOpts::default();
931        assert!(!apply_event(
932            &mut value,
933            &mut sel,
934            "n",
935            &opts,
936            &key_event("n:field", UiKey::Tab, KeyModifiers::default()),
937        ));
938        assert_eq!(value, "3");
939    }
940
941    #[test]
942    fn stacked_variant_has_field_and_chevron_column() {
943        let value = String::from("0");
944        let sel = Selection::default();
945        let opts = NumericInputOpts::default().stacked();
946        let el = numeric_input(&value, &sel, "n", opts);
947        assert_eq!(el.key.as_deref(), Some("n"));
948        // Two children in the stacked layout: the text field and the
949        // chevron column. The inc/dec keys live one level deeper, on
950        // the column's children.
951        assert_eq!(el.children.len(), 2, "field + chevron column");
952        assert_eq!(el.children[0].key.as_deref(), Some("n:field"));
953        let column_children = &el.children[1].children;
954        assert_eq!(column_children.len(), 2, "chevron-up over chevron-down");
955        assert_eq!(column_children[0].key.as_deref(), Some("n:inc"));
956        assert_eq!(column_children[1].key.as_deref(), Some("n:dec"));
957    }
958
959    #[test]
960    fn stacked_variant_keeps_apply_event_contract() {
961        // The stacked layout reuses the same routed key vocabulary, so
962        // apply_event is layout-agnostic.
963        let mut value = String::from("3");
964        let mut sel = Selection::default();
965        let opts = NumericInputOpts::default().stacked();
966        assert!(apply_event(
967            &mut value,
968            &mut sel,
969            "n",
970            &opts,
971            &click("n:inc"),
972        ));
973        assert_eq!(value, "4");
974        assert!(apply_event(
975            &mut value,
976            &mut sel,
977            "n",
978            &opts,
979            &key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
980        ));
981        assert_eq!(value, "3");
982    }
983}