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