Skip to main content

aetna_core/widgets/
numeric_input.rs

1//! Numeric input — text input with `−` / `+` spinner buttons.
2//!
3//! shadcn doesn't ship a dedicated component (web apps lean on
4//! `<input type="number">` and let the browser draw spinners); for a
5//! renderer-agnostic UI kit we render the spinners explicitly so the
6//! affordance is consistent across backends.
7//!
8//! The app owns the value as a `String` (matching [`crate::widgets::text_input`]) so
9//! mid-edit states like `"1."` aren't clobbered by a parse-and-reformat
10//! round-trip on every keystroke. Parse to a number with
11//! `s.parse::<f64>()` (or `i64`, …) when you actually need the value.
12//!
13//! ```ignore
14//! use aetna_core::prelude::*;
15//!
16//! struct Form {
17//!     count: String,
18//!     selection: Selection,
19//! }
20//!
21//! impl App for Form {
22//!     fn build(&self, _cx: &BuildCx) -> El {
23//!         let opts = NumericInputOpts::default()
24//!             .min(0.0)
25//!             .max(100.0)
26//!             .step(1.0);
27//!         numeric_input(&self.count, &self.selection, "count", opts)
28//!     }
29//!
30//!     fn on_event(&mut self, e: UiEvent) {
31//!         let opts = NumericInputOpts::default()
32//!             .min(0.0)
33//!             .max(100.0)
34//!             .step(1.0);
35//!         numeric_input::apply_event(
36//!             &mut self.count, &mut self.selection, "count", &opts, &e,
37//!         );
38//!     }
39//! }
40//! ```
41//!
42//! # Routed keys
43//!
44//! - `{key}:dec` — `Click` on the `−` button. Steps the value down.
45//! - `{key}:inc` — `Click` on the `+` button. Steps the value up.
46//! - `{key}:field` — the inner [`crate::widgets::text_input`]; routed text edits / IME
47//!   commits / pointer caret moves all flow through this key.
48//!
49//! Spinner clicks parse the current `value`, add or subtract
50//! `opts.step`, clamp to `opts.min`/`opts.max` if set, and write the
51//! formatted result back. If the value can't be parsed (empty or
52//! garbage), the spinner treats it as `min` when set, otherwise as
53//! `0.0`.
54//!
55//! # Dogfood note
56//!
57//! Composes only the public widget-kit surface: a `row` with two
58//! ghost [`button`]s and an inner [`text_input_with`]. An app crate
59//! can fork this file to add a different spinner shape (stacked
60//! arrows, wheel-on-scroll, named units) without touching library
61//! internals.
62
63use std::panic::Location;
64
65use crate::event::{UiEvent, UiEventKind};
66use crate::selection::Selection;
67use crate::tokens;
68use crate::tree::*;
69use crate::widgets::button::button;
70use crate::widgets::text_input::{
71    TextInputOpts, apply_event_with as text_input_apply, text_input_with,
72};
73
74/// Configuration for [`numeric_input`] / [`apply_event`].
75///
76/// Defaults: no min, no max, `step = 1.0`, no fixed precision, no
77/// placeholder. The same value is expected to be available both at
78/// build-time (for the placeholder) and at event-time (so spinner
79/// clicks know how much to step and where to clamp), so this is a
80/// struct the app holds onto rather than chained modifiers on the
81/// returned `El` — the same pattern [`TextInputOpts`] uses.
82#[derive(Clone, Copy, Debug)]
83pub struct NumericInputOpts<'a> {
84    /// Lower bound. Spinner clicks clamp to at least this value.
85    /// `None` means unbounded below.
86    pub min: Option<f64>,
87    /// Upper bound. Spinner clicks clamp to at most this value.
88    /// `None` means unbounded above.
89    pub max: Option<f64>,
90    /// Increment for one spinner click. Default `1.0`.
91    pub step: f64,
92    /// Fixed decimal places for the formatted result.
93    /// `None` means: integral values render as `42`, non-integral via
94    /// `f64::Display`. `Some(n)` always formats with `n` decimals
95    /// (e.g. `Some(2)` produces `"3.50"`).
96    pub decimals: Option<u8>,
97    /// Muted hint shown only while `value` is empty.
98    pub placeholder: Option<&'a str>,
99}
100
101impl Default for NumericInputOpts<'_> {
102    fn default() -> Self {
103        Self {
104            min: None,
105            max: None,
106            step: 1.0,
107            decimals: None,
108            placeholder: None,
109        }
110    }
111}
112
113impl<'a> NumericInputOpts<'a> {
114    pub fn min(mut self, v: f64) -> Self {
115        self.min = Some(v);
116        self
117    }
118    pub fn max(mut self, v: f64) -> Self {
119        self.max = Some(v);
120        self
121    }
122    pub fn step(mut self, v: f64) -> Self {
123        self.step = v;
124        self
125    }
126    pub fn decimals(mut self, v: u8) -> Self {
127        self.decimals = Some(v);
128        self
129    }
130    pub fn placeholder(mut self, p: &'a str) -> Self {
131        self.placeholder = Some(p);
132        self
133    }
134}
135
136/// A numeric input field: `[−] [text_input] [+]`.
137///
138/// The two spinner buttons are routed `{key}:dec` and `{key}:inc`;
139/// the inner text input is keyed `{key}:field`. The wrapping `row` is
140/// keyed `{key}` itself so layout/test code can find the whole
141/// composite by the same name the app uses.
142#[track_caller]
143pub fn numeric_input(
144    value: &str,
145    selection: &Selection,
146    key: &str,
147    opts: NumericInputOpts<'_>,
148) -> El {
149    let caller = Location::caller();
150
151    let dec = button("−")
152        .at_loc(caller)
153        .key(format!("{key}:dec"))
154        .ghost()
155        .width(Size::Fixed(tokens::CONTROL_HEIGHT))
156        .height(Size::Fixed(tokens::CONTROL_HEIGHT));
157    let inc = button("+")
158        .at_loc(caller)
159        .key(format!("{key}:inc"))
160        .ghost()
161        .width(Size::Fixed(tokens::CONTROL_HEIGHT))
162        .height(Size::Fixed(tokens::CONTROL_HEIGHT));
163
164    let mut text_opts = TextInputOpts::default();
165    if let Some(p) = opts.placeholder {
166        text_opts = text_opts.placeholder(p);
167    }
168    let field_key = format!("{key}:field");
169    let field = text_input_with(value, selection, &field_key, text_opts).width(Size::Fill(1.0));
170
171    // RING_WIDTH gap: each of `dec`, `field`, and `inc` is independently
172    // focusable, so a literal-zero gap means each focusable's right
173    // ring band gets painted over by the next sibling on the row.
174    // Two pixels of separation keeps the controls visually joined
175    // while leaving the ring uncut.
176    row([dec, field, inc])
177        .at_loc(caller)
178        .key(key.to_string())
179        .gap(tokens::RING_WIDTH)
180        .align(Align::Center)
181        .height(Size::Fixed(tokens::CONTROL_HEIGHT))
182}
183
184/// Fold a routed [`UiEvent`] into the numeric input's value, handling
185/// both spinner clicks and text edits. Returns `true` if the event
186/// belonged to this widget (regardless of whether the value changed).
187///
188/// Spinner clicks parse the current `value`, step by `opts.step`,
189/// clamp to `opts.min`/`opts.max`, and rewrite `value` formatted per
190/// `opts.decimals`. Text edits are forwarded verbatim to
191/// [`crate::widgets::text_input::apply_event`] — no parse / reformat cycle, so a
192/// half-typed `"1."` keeps its cursor position.
193pub fn apply_event(
194    value: &mut String,
195    selection: &mut Selection,
196    key: &str,
197    opts: &NumericInputOpts<'_>,
198    event: &UiEvent,
199) -> bool {
200    if matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
201        let inc_key = format!("{key}:inc");
202        let dec_key = format!("{key}:dec");
203        if event.route() == Some(inc_key.as_str()) {
204            step_value(value, opts, 1);
205            return true;
206        }
207        if event.route() == Some(dec_key.as_str()) {
208            step_value(value, opts, -1);
209            return true;
210        }
211    }
212
213    // Only consume text events that actually target the inner field.
214    // text_input::apply_event itself doesn't gate on target_key
215    // (callers do, see the per-input dispatch in the Inputs section);
216    // forwarding every event would steal keystrokes meant for sibling
217    // widgets and dump them into our value.
218    let field_key = format!("{key}:field");
219    if event.target_key() != Some(field_key.as_str()) {
220        return false;
221    }
222
223    let text_opts = match opts.placeholder {
224        Some(p) => TextInputOpts::default().placeholder(p),
225        None => TextInputOpts::default(),
226    };
227
228    // Run the text_input edit, then revert if the post-edit value
229    // contains non-numeric characters. The filter is permissive: any
230    // char in `[0-9.eE+\-]` is allowed so mid-edit states like `"-"`,
231    // `"1."`, or `"1.5e+"` keep the cursor where the user expects
232    // while the value isn't yet a complete f64.
233    let prev_value = value.clone();
234    let prev_selection = selection.clone();
235    let changed = text_input_apply(value, selection, &field_key, event, &text_opts);
236    if changed && !is_acceptable_numeric_progress(value) {
237        *value = prev_value;
238        *selection = prev_selection;
239        return false;
240    }
241    changed
242}
243
244fn is_acceptable_numeric_progress(s: &str) -> bool {
245    s.is_empty()
246        || s.chars()
247            .all(|c| matches!(c, '0'..='9' | '.' | 'e' | 'E' | '+' | '-'))
248}
249
250fn step_value(value: &mut String, opts: &NumericInputOpts<'_>, dir: i32) {
251    // Treat unparseable input as `min` if set, else 0 — same shape as
252    // browsers' default for `<input type="number">` arrow clicks
253    // against an empty field.
254    let parsed = value
255        .parse::<f64>()
256        .ok()
257        .unwrap_or_else(|| opts.min.unwrap_or(0.0));
258    let stepped = parsed + (dir as f64) * opts.step;
259    let clamped = clamp_opt(stepped, opts.min, opts.max);
260    *value = format_numeric(clamped, opts.decimals);
261}
262
263fn clamp_opt(n: f64, min: Option<f64>, max: Option<f64>) -> f64 {
264    let n = if let Some(hi) = max { n.min(hi) } else { n };
265    if let Some(lo) = min { n.max(lo) } else { n }
266}
267
268fn format_numeric(n: f64, decimals: Option<u8>) -> String {
269    match decimals {
270        Some(d) => format!("{:.*}", d as usize, n),
271        None if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e18 => {
272            // Integral: render without trailing ".0" so the canonical
273            // round-trip of `numeric_input("0", ...) → click + → "1"`
274            // doesn't drift to "1.0".
275            format!("{}", n as i64)
276        }
277        None => format!("{n}"),
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::event::{KeyModifiers, UiTarget};
285    use crate::tree::Rect;
286
287    fn click(key: &str) -> UiEvent {
288        UiEvent::synthetic_click(key)
289    }
290
291    /// Build a TextInput event targeting `target_key` with `text` as
292    /// the composed payload. Used to drive both the routing-gate and
293    /// the numeric-character-filter tests.
294    fn text_event(target_key: &str, text: &str) -> UiEvent {
295        UiEvent {
296            path: None,
297            key: Some(target_key.to_string()),
298            target: Some(UiTarget {
299                key: target_key.to_string(),
300                node_id: format!("/{target_key}"),
301                rect: Rect::new(0.0, 0.0, 100.0, 32.0),
302                tooltip: None,
303                scroll_offset_y: 0.0,
304            }),
305            pointer: None,
306            key_press: None,
307            text: Some(text.to_string()),
308            selection: None,
309            modifiers: KeyModifiers::default(),
310            click_count: 0,
311            kind: UiEventKind::TextInput,
312        }
313    }
314
315    #[test]
316    fn inc_steps_value_up_by_step() {
317        let mut value = String::from("3");
318        let mut sel = Selection::default();
319        let opts = NumericInputOpts::default().step(2.0);
320        assert!(apply_event(
321            &mut value,
322            &mut sel,
323            "n",
324            &opts,
325            &click("n:inc")
326        ));
327        assert_eq!(value, "5");
328    }
329
330    #[test]
331    fn dec_steps_value_down_by_step() {
332        let mut value = String::from("3");
333        let mut sel = Selection::default();
334        let opts = NumericInputOpts::default().step(0.5).decimals(1);
335        assert!(apply_event(
336            &mut value,
337            &mut sel,
338            "n",
339            &opts,
340            &click("n:dec")
341        ));
342        assert_eq!(value, "2.5");
343    }
344
345    #[test]
346    fn inc_clamps_to_max() {
347        let mut value = String::from("99");
348        let mut sel = Selection::default();
349        let opts = NumericInputOpts::default().min(0.0).max(100.0);
350        // 99 + 1*5 = 104, clamped to 100.
351        let opts = opts.step(5.0);
352        assert!(apply_event(
353            &mut value,
354            &mut sel,
355            "n",
356            &opts,
357            &click("n:inc")
358        ));
359        assert_eq!(value, "100");
360    }
361
362    #[test]
363    fn dec_clamps_to_min() {
364        let mut value = String::from("1");
365        let mut sel = Selection::default();
366        let opts = NumericInputOpts::default().min(0.0).max(100.0);
367        assert!(apply_event(
368            &mut value,
369            &mut sel,
370            "n",
371            &opts,
372            &click("n:dec")
373        ));
374        assert_eq!(value, "0");
375        // Already at min — another dec stays at 0.
376        assert!(apply_event(
377            &mut value,
378            &mut sel,
379            "n",
380            &opts,
381            &click("n:dec")
382        ));
383        assert_eq!(value, "0");
384    }
385
386    #[test]
387    fn empty_value_treated_as_min_when_set() {
388        let mut value = String::new();
389        let mut sel = Selection::default();
390        let opts = NumericInputOpts::default().min(10.0).max(100.0);
391        // Empty → starts at min (10), then +1 → 11.
392        assert!(apply_event(
393            &mut value,
394            &mut sel,
395            "n",
396            &opts,
397            &click("n:inc")
398        ));
399        assert_eq!(value, "11");
400    }
401
402    #[test]
403    fn empty_value_treated_as_zero_when_no_min() {
404        let mut value = String::new();
405        let mut sel = Selection::default();
406        let opts = NumericInputOpts::default();
407        assert!(apply_event(
408            &mut value,
409            &mut sel,
410            "n",
411            &opts,
412            &click("n:inc")
413        ));
414        assert_eq!(value, "1");
415    }
416
417    #[test]
418    fn unparseable_value_treated_as_zero_when_no_min() {
419        let mut value = String::from("abc");
420        let mut sel = Selection::default();
421        let opts = NumericInputOpts::default();
422        assert!(apply_event(
423            &mut value,
424            &mut sel,
425            "n",
426            &opts,
427            &click("n:inc")
428        ));
429        assert_eq!(value, "1");
430    }
431
432    #[test]
433    fn ignores_unrelated_keys() {
434        let mut value = String::from("3");
435        let mut sel = Selection::default();
436        let opts = NumericInputOpts::default();
437        // Different key family — should not match this widget.
438        assert!(!apply_event(
439            &mut value,
440            &mut sel,
441            "n",
442            &opts,
443            &click("other:inc")
444        ));
445        assert_eq!(value, "3");
446    }
447
448    #[test]
449    fn decimals_format_pads_zeros() {
450        let mut value = String::from("0");
451        let mut sel = Selection::default();
452        let opts = NumericInputOpts::default().step(0.10).decimals(2);
453        assert!(apply_event(
454            &mut value,
455            &mut sel,
456            "n",
457            &opts,
458            &click("n:inc")
459        ));
460        assert_eq!(value, "0.10");
461    }
462
463    #[test]
464    fn no_decimals_strips_trailing_zero() {
465        let mut value = String::from("0");
466        let mut sel = Selection::default();
467        let opts = NumericInputOpts::default().step(1.0);
468        assert!(apply_event(
469            &mut value,
470            &mut sel,
471            "n",
472            &opts,
473            &click("n:inc")
474        ));
475        // 1.0 → "1", not "1.0" (we only fall through to `f64::Display`
476        // when the result has a fractional component).
477        assert_eq!(value, "1");
478    }
479
480    #[test]
481    fn text_event_for_other_widget_is_ignored() {
482        // Regression: previously `apply_event` forwarded every
483        // non-spinner event into `text_input::apply_event`, which
484        // doesn't gate on target_key — so typing into a sibling
485        // text input would also write into the numeric input.
486        let mut value = String::from("42");
487        let mut sel = Selection::default();
488        let opts = NumericInputOpts::default();
489        // A TextInput event targeted at a sibling widget should not
490        // touch our value at all.
491        assert!(!apply_event(
492            &mut value,
493            &mut sel,
494            "n",
495            &opts,
496            &text_event("other-input", "x"),
497        ));
498        assert_eq!(value, "42");
499    }
500
501    #[test]
502    fn text_event_filter_rejects_non_numeric_chars() {
503        // A TextInput event targeting our inner field whose payload
504        // isn't numeric is rolled back so the value never absorbs
505        // letters / punctuation.
506        let mut value = String::from("12");
507        let mut sel = Selection::default();
508        let opts = NumericInputOpts::default();
509        assert!(!apply_event(
510            &mut value,
511            &mut sel,
512            "n",
513            &opts,
514            &text_event("n:field", "abc"),
515        ));
516        assert_eq!(value, "12");
517    }
518
519    #[test]
520    fn text_event_filter_accepts_partial_numeric_states() {
521        // Mid-edit values are kept: bare `-`, trailing `.`, exponent
522        // prefix, etc. should all pass the filter even though they
523        // aren't yet a complete f64.
524        for partial in ["-", "1.", "1.5e", "1.5e+", ".5", "+"] {
525            let mut value = String::new();
526            let mut sel = Selection::default();
527            let opts = NumericInputOpts::default();
528            assert!(
529                apply_event(
530                    &mut value,
531                    &mut sel,
532                    "n",
533                    &opts,
534                    &text_event("n:field", partial),
535                ),
536                "filter should accept partial value {partial:?}",
537            );
538            assert_eq!(value, partial, "value should equal {partial:?}");
539        }
540    }
541
542    #[test]
543    fn text_event_filter_accepts_full_numeric_paste() {
544        let mut value = String::new();
545        let mut sel = Selection::default();
546        let opts = NumericInputOpts::default();
547        assert!(apply_event(
548            &mut value,
549            &mut sel,
550            "n",
551            &opts,
552            &text_event("n:field", "42.5"),
553        ));
554        assert_eq!(value, "42.5");
555    }
556
557    #[test]
558    fn build_widget_has_three_children_and_correct_keys() {
559        let value = String::from("0");
560        let sel = Selection::default();
561        let opts = NumericInputOpts::default();
562        let el = numeric_input(&value, &sel, "n", opts);
563        assert_eq!(el.key.as_deref(), Some("n"));
564        assert_eq!(el.children.len(), 3, "decrement, field, increment");
565        assert_eq!(el.children[0].key.as_deref(), Some("n:dec"));
566        assert_eq!(el.children[1].key.as_deref(), Some("n:field"));
567        assert_eq!(el.children[2].key.as_deref(), Some("n:inc"));
568    }
569}