Skip to main content

qframe/widgets/
number_input.rs

1//! Number entry.
2
3use super::cells;
4use super::edit_menu;
5use super::numeric::Steps;
6use super::text_input::TextInput;
7use crate::event::{Event, MouseButton, MouseKind};
8use crate::geometry::{Rect, Size};
9use crate::keymap::Key;
10use crate::style::CellStyle;
11use crate::text;
12use crate::theme::State;
13use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
14
15/// Width of one stepper segment: the sign with a cell of room on each side.
16const SEGMENT: u16 = 3;
17
18/// Steps in a Page Up or Page Down.
19const LARGE_STEP: f64 = 10.0;
20
21/// Cells a field without a range leaves for its number.
22const DEFAULT_DIGITS: u16 = 8;
23
24/// Builds a message from a new value.
25type ValueMessage<Msg> = Box<dyn Fn(f64) -> Msg>;
26
27/// A text field for a number: typing edits it like a text input, Up and Down step it.
28///
29/// Only digits can be typed, a minus when the range allows negative numbers and a decimal
30/// point when the step has decimals. The field shows the invalid state while its text is not a
31/// number or lies outside the range, and the application only ever receives valid numbers
32/// inside the range; an empty or unfinished field keeps the last valid value. Up and Down move
33/// one step and Page Up and Page Down ten, always clamped to the range. Everything else edits
34/// like a [`TextInput`](crate::widgets::TextInput): cursor, selection, undo and clipboard.
35///
36/// With [`NumberInput::steppers`] two segments on the right step the value when clicked. The
37/// wheel over the field moves one step per notch, and a right click opens the text input's edit
38/// menu (Cut, Copy, Paste, Select all); pasted text keeps only the characters a number allows.
39///
40/// Style keys: those of the text input (`text-input`, `text-input-prompt`, …) and
41/// `number-input-stepper` (`bg`, `fg`) with states `hover`, `focus`, `pressed`, `disabled`.
42pub struct NumberInput<Msg> {
43    value: f64,
44    steps: Steps,
45    steppers: bool,
46    placeholder: String,
47    invalid: bool,
48    disabled: bool,
49    on_change: Option<ValueMessage<Msg>>,
50}
51
52#[derive(Debug, Default)]
53struct NumberMemory {
54    /// The text being edited, which may be unfinished such as `-` or `2.`.
55    draft: String,
56    /// The value the draft was last written from or sent as.
57    seen: Option<f64>,
58    /// The decimal separator the draft was written with, so a language changed while the field
59    /// stands there rewrites it instead of leaving the old language's point on screen.
60    separator: Option<char>,
61    /// The stepper segment that was clicked last: 0 down, 1 up.
62    pressed: Option<usize>,
63}
64
65impl<Msg: 'static> NumberInput<Msg> {
66    /// A field showing `value`, without limits, stepping by 1.
67    #[must_use]
68    pub fn new(value: f64) -> Self {
69        Self {
70            value,
71            steps: Steps::new(f64::NEG_INFINITY, f64::INFINITY, 1.0),
72            steppers: false,
73            placeholder: String::new(),
74            invalid: false,
75            disabled: false,
76            on_change: None,
77        }
78    }
79
80    /// The smallest and largest allowed value.
81    #[must_use]
82    pub fn range(mut self, min: f64, max: f64) -> Self {
83        self.steps = Steps::new(min, max, self.steps.step);
84        self
85    }
86
87    /// How far Up and Down move the value. The step's decimals decide how the value is written
88    /// and whether a decimal point can be typed.
89    #[must_use]
90    pub fn step(mut self, step: f64) -> Self {
91        self.steps = Steps::new(self.steps.min, self.steps.max, step);
92        self
93    }
94
95    /// Shows clickable minus and plus segments on the right.
96    #[must_use]
97    pub fn steppers(mut self, steppers: bool) -> Self {
98        self.steppers = steppers;
99        self
100    }
101
102    /// Faint text shown while the field is empty.
103    #[must_use]
104    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
105        self.placeholder = text.into();
106        self
107    }
108
109    /// Marks the value as failing the application's own validation.
110    #[must_use]
111    pub fn invalid(mut self, invalid: bool) -> Self {
112        self.invalid = invalid;
113        self
114    }
115
116    /// Makes the field read-only and unfocusable.
117    #[must_use]
118    pub fn disabled(mut self, disabled: bool) -> Self {
119        self.disabled = disabled;
120        self
121    }
122
123    /// Message carrying the new value after every change to a valid number.
124    #[must_use]
125    pub fn on_change(mut self, message: impl Fn(f64) -> Msg + 'static) -> Self {
126        self.on_change = Some(Box::new(message));
127        self
128    }
129
130    /// The draft, rewritten from the value when the application changed it or the language did.
131    fn draft(&self, memory: &mut NumberMemory, separator: char) -> String {
132        if memory.seen != Some(self.value) || memory.separator != Some(separator) {
133            memory.draft = self.steps.write_with(self.value, separator);
134            memory.seen = Some(self.value);
135            memory.separator = Some(separator);
136        }
137        memory.draft.clone()
138    }
139
140    /// The number in `draft` when it is one inside the range.
141    fn parse(&self, draft: &str, separator: char) -> Option<f64> {
142        let value = read(draft, separator)?;
143        (value >= self.steps.min && value <= self.steps.max).then_some(value)
144    }
145
146    /// The text field that edits `draft`; it is invalid while the draft is not a number in the
147    /// range.
148    fn field(&self, draft: String, separator: char) -> TextInput<Msg> {
149        let negative = self.steps.min < 0.0;
150        let decimal = self.steps.decimals() > 0;
151        let broken = !draft.is_empty() && self.parse(&draft, separator).is_none();
152        TextInput::new(draft)
153            .placeholder(self.placeholder.clone())
154            .invalid(self.invalid || broken)
155            .disabled(self.disabled)
156            .accept(move |c| c.is_ascii_digit() || (negative && c == '-') || (decimal && (c == '.' || c == separator)))
157    }
158
159    /// The two stepper segments inside `area`: down, then up.
160    fn segments(&self, area: Rect) -> Option<[Rect; 2]> {
161        if !self.steppers || area.width < SEGMENT * 2 {
162            return None;
163        }
164        let up = Rect::new(area.right() - i32::from(SEGMENT), area.y, SEGMENT, area.height);
165        let down = Rect::new(up.x - i32::from(SEGMENT), area.y, SEGMENT, area.height);
166        Some([down, up])
167    }
168
169    /// Moves the value by `count` steps from the draft (or the value when the draft is not a
170    /// number) and sends it.
171    fn nudge(&self, cx: &mut EventCx<'_, Msg>, count: f64) {
172        let separator = cx.env().i18n().decimal_separator();
173        let memory = cx.memory::<NumberMemory>();
174        let draft = self.draft(memory, separator);
175        let base = read(&draft, separator).unwrap_or(self.value);
176        let value = self.steps.clamp(base + count * self.steps.step);
177        memory.draft = self.steps.write_with(value, separator);
178        memory.seen = Some(value);
179        memory.separator = Some(separator);
180        if value != self.value
181            && let Some(message) = &self.on_change
182        {
183            cx.emit(message(value));
184        }
185    }
186}
187
188impl<Msg: 'static> Widget<Msg> for NumberInput<Msg> {
189    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
190        let style = cx.env().theme().style("text-input", None, &[]);
191        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
192        let prompt = text::width(&cx.env().icons().glyph("prompt")) + 1;
193        let bounds = [self.steps.min, self.steps.max, self.value]
194            .iter()
195            .filter(|value| value.is_finite())
196            .map(|value| text::width(&self.steps.write(*value)))
197            .max()
198            .unwrap_or(0);
199        let digits = if self.steps.min.is_finite() && self.steps.max.is_finite() { bounds } else { DEFAULT_DIGITS };
200        let content = digits.max(text::width(&self.placeholder)).max(bounds).saturating_add(1);
201        let steppers = if self.steppers { SEGMENT * 2 } else { 0 };
202        Size::new(
203            cells::sum([prompt, horizontal.saturating_mul(2), content, steppers]),
204            vertical.saturating_mul(2).saturating_add(1),
205        )
206        .min(available)
207    }
208
209    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
210        let separator = cx.env().i18n().decimal_separator();
211        let draft = self.draft(cx.memory::<NumberMemory>(), separator);
212        let segments = self.segments(area);
213        let field_width = area.width.saturating_sub(if segments.is_some() { SEGMENT * 2 } else { 0 });
214        self.field(draft, separator).paint(cx, Rect::new(area.x, area.y, field_width, area.height));
215        let Some(segments) = segments else {
216            return;
217        };
218        if !self.disabled {
219            cx.register_hit(area);
220        }
221        let focused = cx.is_focused();
222        let pressed = if cx.is_pressed() { cx.memory::<NumberMemory>().pressed } else { None };
223        let pointer = if self.disabled { None } else { cx.pointer() };
224        let limits = [self.value <= self.steps.min, self.value >= self.steps.max];
225        for (index, (rect, glyph_key)) in segments.into_iter().zip(["stepper-minus", "stepper-plus"]).enumerate() {
226            let mut states = Vec::new();
227            if pointer.is_some_and(|(x, y)| rect.contains(x, y)) {
228                states.push(State::Hover);
229            }
230            if focused {
231                states.push(State::Focus);
232            }
233            if pressed == Some(index) {
234                states.push(State::Pressed);
235            }
236            if self.disabled || limits[index] {
237                states.push(State::Disabled);
238            }
239            let style = cx.style("number-input-stepper", None, &states).text();
240            let background = style.bg.unwrap_or_else(|| cx.color("active"));
241            cx.clear(rect, background);
242            let glyph = cx.env().icons().glyph(glyph_key).into_owned();
243            let x = rect.x + i32::from(SEGMENT.saturating_sub(text::width(&glyph)) / 2);
244            let y = rect.y + i32::from(rect.height / 2);
245            cx.text(x, y, &glyph, CellStyle { bg: None, ..style }, SEGMENT);
246        }
247    }
248
249    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
250        let separator = cx.env().i18n().decimal_separator();
251        let draft = self.draft(cx.memory::<NumberMemory>(), separator);
252        self.field(draft, separator).paint_overlay(cx, anchor);
253    }
254
255    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
256        if self.disabled {
257            return false;
258        }
259        // The edit menu, open or asked for, takes the event before the steps do.
260        let menu = edit_menu::is_open(cx) || edit_menu::asks(event);
261        match event {
262            _ if menu => {}
263            Event::Key(key) => {
264                let count = if key.is_plain(Key::Up) {
265                    1.0
266                } else if key.is_plain(Key::Down) {
267                    -1.0
268                } else if key.is_plain(Key::PageUp) {
269                    LARGE_STEP
270                } else if key.is_plain(Key::PageDown) {
271                    -LARGE_STEP
272                } else {
273                    0.0
274                };
275                if count != 0.0 {
276                    self.nudge(cx, count);
277                    return true;
278                }
279            }
280            Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
281                self.nudge(cx, if mouse.kind == MouseKind::ScrollUp { 1.0 } else { -1.0 });
282                return true;
283            }
284            Event::Mouse(mouse) => {
285                let hit = self
286                    .segments(cx.area())
287                    .and_then(|segments| segments.iter().position(|rect| rect.contains(mouse.x, mouse.y)));
288                if let Some(index) = hit {
289                    if mouse.kind == MouseKind::Down(MouseButton::Left) {
290                        cx.memory::<NumberMemory>().pressed = Some(index);
291                        cx.flash();
292                        self.nudge(cx, if index == 0 { -1.0 } else { 1.0 });
293                    }
294                    return true;
295                }
296            }
297            Event::Paste(_) | Event::PointerOutside => {}
298        }
299        let separator = cx.env().i18n().decimal_separator();
300        let draft = self.draft(cx.memory::<NumberMemory>(), separator);
301        let edit = self.field(draft, separator).edit(cx, event);
302        if let Some(text) = edit.changed {
303            let value = self.parse(&text, separator);
304            let memory = cx.memory::<NumberMemory>();
305            memory.draft = text;
306            if let Some(value) = value
307                && value != self.value
308            {
309                memory.seen = Some(value);
310                if let Some(message) = &self.on_change {
311                    cx.emit(message(value));
312                }
313            }
314        }
315        edit.handled
316    }
317
318    fn focusable(&self) -> bool {
319        !self.disabled
320    }
321}
322
323/// The number `text` holds, written either with `separator`, the language's own, or with a point,
324/// which a numeric keypad gives whatever the language is.
325fn read(text: &str, separator: char) -> Option<f64> {
326    let text = if separator == '.' { text.to_owned() } else { text.replace(separator, ".") };
327    text.parse::<f64>().ok().filter(|value| value.is_finite())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::runtime::{App, Command, Harness};
334    use crate::widget::{Length, View};
335
336    struct Demo {
337        value: f64,
338        steppers: bool,
339        step: f64,
340    }
341
342    impl App for Demo {
343        type Msg = f64;
344        fn update(&mut self, value: f64) -> Command<f64> {
345            self.value = value;
346            Command::none()
347        }
348        fn view(&self, ui: &mut View<'_, f64>) {
349            ui.add(
350                NumberInput::new(self.value)
351                    .range(-5.0, 20.0)
352                    .step(self.step)
353                    .steppers(self.steppers)
354                    .on_change(|value| value),
355            )
356            .width(Length::Cells(16))
357            .id("replicas");
358        }
359    }
360
361    fn harness(value: f64, steppers: bool, step: f64) -> Harness<Demo> {
362        Harness::new(Demo { value, steppers, step }, 20, 1)
363    }
364
365    #[test]
366    fn the_field_writes_and_reads_the_language_s_own_decimal_separator() {
367        let mut h = harness(3.0, false, 0.5);
368        assert_eq!(h.screen(), "  ❯ 3.0\n", "English writes a point");
369        h.set_locale("fr");
370        assert_eq!(h.screen(), "  ❯ 3,0\n", "a language changed while the field stands there rewrites it");
371        // The comma the field shows is the one that can be typed into it.
372        h.press("tab").press("backspace").type_text("5");
373        assert_eq!(h.screen(), "▌ ❯ 3,5\n");
374        assert_eq!(h.app().value, 3.5, "the comma reached the value");
375        // A numeric keypad gives a point whatever the language is, and it reads the same.
376        h.press("backspace").type_text("5");
377        assert_eq!(h.app().value, 3.5);
378        h.set_locale("en");
379        assert_eq!(h.screen(), "▌ ❯ 3.5\n", "and back the other way");
380    }
381
382    #[test]
383    fn arrows_step_and_clamp_to_the_range() {
384        let mut h = harness(3.0, false, 1.0);
385        assert_eq!(h.screen(), "  ❯ 3\n");
386        h.press("tab").press("up").press("up");
387        assert_eq!(h.app().value, 5.0);
388        h.press("pgup").press("pgup");
389        assert_eq!(h.app().value, 20.0);
390        assert_eq!(h.screen(), "▌ ❯ 20\n");
391        h.press("pgdn").press("pgdn").press("pgdn");
392        assert_eq!(h.app().value, -5.0);
393    }
394
395    #[test]
396    fn typing_accepts_numbers_only_and_marks_unfinished_or_out_of_range_text() {
397        let mut h = harness(3.0, false, 1.0);
398        let focused = h.press("tab").bg(0, 0);
399        h.press("backspace").type_text("1x2.");
400        assert_eq!(h.screen(), "▌ ❯ 12\n", "letters and the point of a whole-number field are ignored");
401        assert_eq!(h.app().value, 12.0);
402        h.type_text("5");
403        assert_eq!(h.app().value, 12.0, "125 is out of range and not sent");
404        assert_ne!(h.bg(0, 0), focused, "the invalid tint replaces the focus surface");
405        h.press("ctrl+a").type_text("-");
406        assert_eq!(h.screen(), "▌ ❯ -\n");
407        assert_eq!(h.app().value, 12.0);
408        h.type_text("4");
409        assert_eq!(h.app().value, -4.0);
410        assert_eq!(h.bg(0, 0), focused);
411    }
412
413    #[test]
414    fn decimal_steps_allow_a_point_and_write_their_decimals() {
415        let mut h = harness(1.0, false, 0.25);
416        assert_eq!(h.screen(), "  ❯ 1.00\n");
417        h.press("tab").press("up");
418        assert_eq!(h.app().value, 1.25);
419        h.press("ctrl+a").type_text("2.5");
420        assert_eq!(h.app().value, 2.5);
421        assert_eq!(h.screen(), "▌ ❯ 2.5\n", "a typed value keeps its own writing");
422    }
423
424    #[test]
425    fn stepper_segments_click_and_grey_out_at_the_limits() {
426        let mut h = harness(19.0, true, 1.0);
427        assert_eq!(h.screen(), "  ❯ 19     −  +\n");
428        h.click(14, 0);
429        assert_eq!(h.app().value, 20.0);
430        assert!(h.is_focused("replicas"));
431        let theme = h.env().theme();
432        assert_eq!(h.fg(14, 0), theme.color("muted"), "plus is spent at the maximum");
433        h.click(11, 0);
434        assert_eq!(h.app().value, 19.0);
435        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
436        assert_eq!(h.screen(), "  > 19     -  +\n");
437    }
438
439    #[test]
440    fn disabled_ignores_keys_and_clicks() {
441        struct Disabled(f64);
442        impl App for Disabled {
443            type Msg = f64;
444            fn update(&mut self, value: f64) -> Command<f64> {
445                self.0 = value;
446                Command::none()
447            }
448            fn view(&self, ui: &mut View<'_, f64>) {
449                ui.add(NumberInput::new(self.0).steppers(true).disabled(true).on_change(|v| v))
450                    .width(Length::Cells(16));
451            }
452        }
453        let mut h = Harness::new(Disabled(4.0), 20, 1);
454        h.press("tab").press("up").click(14, 0).type_text("9");
455        assert_eq!(h.app().0, 4.0);
456        assert_eq!(h.fg(4, 0), h.env().theme().color("muted"));
457    }
458
459    #[test]
460    fn the_wheel_steps_and_a_right_click_opens_the_edit_menu() {
461        let mut h = Harness::new(Demo { value: 3.0, steppers: false, step: 1.0 }, 30, 6);
462        h.set_reduced_motion(true);
463        h.mouse(MouseKind::ScrollUp, 4, 0).mouse(MouseKind::ScrollUp, 4, 0);
464        assert_eq!(h.app().value, 5.0, "one step per notch");
465        h.mouse(MouseKind::ScrollDown, 4, 0);
466        assert_eq!(h.app().value, 4.0);
467        h.mouse(MouseKind::Down(MouseButton::Right), 4, 0).mouse(MouseKind::Up(MouseButton::Right), 4, 0);
468        assert!(h.screen().contains("Select all"), "{}", h.screen());
469        h.press("up").press("enter");
470        assert!(!h.screen().contains("Select all"), "the menu took ↑ and Enter");
471        assert_eq!(h.app().value, 4.0, "↑ moved the highlight, not the value");
472        h.set_system_clipboard(Some("12 replicas")).mouse(MouseKind::Down(MouseButton::Right), 4, 0);
473        h.click_text("Paste");
474        assert_eq!(h.app().value, 12.0, "Select all, then Paste kept the digits");
475    }
476
477    #[test]
478    fn external_changes_replace_the_draft() {
479        let mut h = harness(3.0, false, 1.0);
480        h.press("tab").press("backspace");
481        assert_eq!(h.screen(), "▌ ❯\n");
482        h.send(7.0);
483        assert_eq!(h.screen(), "▌ ❯ 7\n");
484    }
485}