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