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 pressed: Option<usize>,
60}
61
62impl<Msg: 'static> NumberInput<Msg> {
63 #[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 #[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 #[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 #[must_use]
94 pub fn steppers(mut self, steppers: bool) -> Self {
95 self.steppers = steppers;
96 self
97 }
98
99 #[must_use]
101 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
102 self.placeholder = text.into();
103 self
104 }
105
106 #[must_use]
108 pub fn invalid(mut self, invalid: bool) -> Self {
109 self.invalid = invalid;
110 self
111 }
112
113 #[must_use]
115 pub fn disabled(mut self, disabled: bool) -> Self {
116 self.disabled = disabled;
117 self
118 }
119
120 #[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 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 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 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 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 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 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}