1use 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
15const SEGMENT: u16 = 3;
17
18const LARGE_STEP: f64 = 10.0;
20
21const DEFAULT_DIGITS: u16 = 8;
23
24type ValueMessage<Msg> = Box<dyn Fn(f64) -> Msg>;
26
27pub 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 draft: String,
56 seen: Option<f64>,
58 separator: Option<char>,
61 pressed: Option<usize>,
63}
64
65impl<Msg: 'static> NumberInput<Msg> {
66 #[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 #[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 #[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 #[must_use]
97 pub fn steppers(mut self, steppers: bool) -> Self {
98 self.steppers = steppers;
99 self
100 }
101
102 #[must_use]
104 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
105 self.placeholder = text.into();
106 self
107 }
108
109 #[must_use]
111 pub fn invalid(mut self, invalid: bool) -> Self {
112 self.invalid = invalid;
113 self
114 }
115
116 #[must_use]
118 pub fn disabled(mut self, disabled: bool) -> Self {
119 self.disabled = disabled;
120 self
121 }
122
123 #[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 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 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 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 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 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 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
323fn 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 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 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}