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