1use std::panic::Location;
86
87use crate::event::{KeyModifiers, UiEvent, UiEventKind, UiKey};
88use crate::selection::Selection;
89use crate::tokens;
90use crate::tree::*;
91use crate::widgets::button::{button, icon_button};
92use crate::widgets::text_input::{
93 TextInputOpts, apply_event_with as text_input_apply, text_input_with,
94};
95
96#[derive(Clone, Copy, Debug)]
105pub struct NumericInputOpts<'a> {
106 pub min: Option<f64>,
109 pub max: Option<f64>,
112 pub step: f64,
114 pub decimals: Option<u8>,
119 pub placeholder: Option<&'a str>,
121 pub stacked: bool,
128}
129
130impl Default for NumericInputOpts<'_> {
131 fn default() -> Self {
132 Self {
133 min: None,
134 max: None,
135 step: 1.0,
136 decimals: None,
137 placeholder: None,
138 stacked: false,
139 }
140 }
141}
142
143impl<'a> NumericInputOpts<'a> {
144 pub fn min(mut self, v: f64) -> Self {
145 self.min = Some(v);
146 self
147 }
148 pub fn max(mut self, v: f64) -> Self {
149 self.max = Some(v);
150 self
151 }
152 pub fn step(mut self, v: f64) -> Self {
153 self.step = v;
154 self
155 }
156 pub fn decimals(mut self, v: u8) -> Self {
157 self.decimals = Some(v);
158 self
159 }
160 pub fn placeholder(mut self, p: &'a str) -> Self {
161 self.placeholder = Some(p);
162 self
163 }
164 pub fn stacked(mut self) -> Self {
167 self.stacked = true;
168 self
169 }
170}
171
172#[track_caller]
181pub fn numeric_input(
182 value: &str,
183 selection: &Selection,
184 key: &str,
185 opts: NumericInputOpts<'_>,
186) -> El {
187 let caller = Location::caller();
188
189 let mut text_opts = TextInputOpts::default();
190 if let Some(p) = opts.placeholder {
191 text_opts = text_opts.placeholder(p);
192 }
193 let field_key = format!("{key}:field");
194 let field = text_input_with(value, selection, &field_key, text_opts).width(Size::Fill(1.0));
195
196 let children: Vec<El> = if opts.stacked {
207 vec![field, stacked_chevron_column(key, caller)]
208 } else {
209 let dec = button("−")
210 .at_loc(caller)
211 .key(format!("{key}:dec"))
212 .ghost()
213 .width(Size::Fixed(tokens::CONTROL_HEIGHT))
214 .height(Size::Fixed(tokens::CONTROL_HEIGHT));
215 let inc = button("+")
216 .at_loc(caller)
217 .key(format!("{key}:inc"))
218 .ghost()
219 .width(Size::Fixed(tokens::CONTROL_HEIGHT))
220 .height(Size::Fixed(tokens::CONTROL_HEIGHT));
221 vec![dec, field, inc]
222 };
223
224 row(children)
225 .at_loc(caller)
226 .key(key.to_string())
227 .gap(tokens::RING_WIDTH)
228 .align(Align::Center)
229 .default_width(Size::Fixed(DEFAULT_WIDTH))
230 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
231}
232
233const STACKED_CHEVRON_WIDTH: f32 = 22.0;
237
238pub const DEFAULT_WIDTH: f32 = 144.0;
251
252fn stacked_chevron_column(key: &str, caller: &'static Location<'static>) -> El {
264 let half_h = (tokens::CONTROL_HEIGHT * 0.5).floor();
265 let inc = icon_button("chevron-up")
266 .at_loc(caller)
267 .key(format!("{key}:inc"))
268 .ghost()
269 .icon_size(tokens::ICON_XS)
270 .focus_ring_inside()
271 .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
272 .height(Size::Fixed(half_h));
273 let dec = icon_button("chevron-down")
274 .at_loc(caller)
275 .key(format!("{key}:dec"))
276 .ghost()
277 .icon_size(tokens::ICON_XS)
278 .focus_ring_inside()
279 .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
280 .height(Size::Fixed(half_h));
281 column([inc, dec])
282 .at_loc(caller)
283 .gap(0.0)
284 .width(Size::Fixed(STACKED_CHEVRON_WIDTH))
285 .height(Size::Fixed(tokens::CONTROL_HEIGHT))
286}
287
288pub fn apply_event(
300 value: &mut String,
301 selection: &mut Selection,
302 key: &str,
303 opts: &NumericInputOpts<'_>,
304 event: &UiEvent,
305) -> bool {
306 if matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
307 let inc_key = format!("{key}:inc");
308 let dec_key = format!("{key}:dec");
309 if event.route() == Some(inc_key.as_str()) {
310 step_value(value, opts, 1, event.modifiers);
311 return true;
312 }
313 if event.route() == Some(dec_key.as_str()) {
314 step_value(value, opts, -1, event.modifiers);
315 return true;
316 }
317 }
318
319 let field_key = format!("{key}:field");
320
321 if event.kind == UiEventKind::KeyDown
327 && event.is_route(&field_key)
328 && let Some(kp) = event.key_press.as_ref()
329 {
330 let dir = match kp.key {
331 UiKey::ArrowUp => Some(1),
332 UiKey::ArrowDown => Some(-1),
333 _ => None,
334 };
335 if let Some(d) = dir {
336 step_value(value, opts, d, kp.modifiers);
337 return true;
338 }
339 }
340
341 if event.target_key() != Some(field_key.as_str()) {
347 return false;
348 }
349
350 let text_opts = match opts.placeholder {
351 Some(p) => TextInputOpts::default().placeholder(p),
352 None => TextInputOpts::default(),
353 };
354
355 let prev_value = value.clone();
361 let prev_selection = selection.clone();
362 let changed = text_input_apply(value, selection, &field_key, event, &text_opts);
363 if changed && !is_acceptable_numeric_progress(value) {
364 *value = prev_value;
365 *selection = prev_selection;
366 return false;
367 }
368 changed
369}
370
371fn is_acceptable_numeric_progress(s: &str) -> bool {
372 s.is_empty()
373 || s.chars()
374 .all(|c| matches!(c, '0'..='9' | '.' | 'e' | 'E' | '+' | '-'))
375}
376
377fn step_value(value: &mut String, opts: &NumericInputOpts<'_>, dir: i32, mods: KeyModifiers) {
378 let parsed = value
382 .parse::<f64>()
383 .ok()
384 .unwrap_or_else(|| opts.min.unwrap_or(0.0));
385 let stepped = parsed + (dir as f64) * opts.step * step_scale(mods);
386 let clamped = clamp_opt(stepped, opts.min, opts.max);
387 *value = format_numeric(clamped, opts.decimals);
388}
389
390fn step_scale(mods: KeyModifiers) -> f64 {
395 if mods.shift {
396 10.0
397 } else if mods.alt {
398 0.1
399 } else {
400 1.0
401 }
402}
403
404fn clamp_opt(n: f64, min: Option<f64>, max: Option<f64>) -> f64 {
405 let n = if let Some(hi) = max { n.min(hi) } else { n };
406 if let Some(lo) = min { n.max(lo) } else { n }
407}
408
409fn format_numeric(n: f64, decimals: Option<u8>) -> String {
410 match decimals {
411 Some(d) => format!("{:.*}", d as usize, n),
412 None if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e18 => {
413 format!("{}", n as i64)
417 }
418 None => format!("{n}"),
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::event::{KeyModifiers, UiTarget};
426 use crate::layout::layout;
427 use crate::state::UiState;
428 use crate::tree::Rect;
429
430 fn click(key: &str) -> UiEvent {
431 UiEvent::synthetic_click(key)
432 }
433
434 #[test]
435 fn default_is_fixed_width_with_inner_field_filling() {
436 let value = String::from("42");
444 let sel = Selection::default();
445 let widget = numeric_input(&value, &sel, "n", NumericInputOpts::default());
446 let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
447 crate::widgets::form::form_control(widget),
448 ])]);
449 let mut state = UiState::new();
450 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
451
452 let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
453 let field_rect = state.rect_of_key(&tree, "n:field").expect("field rect");
454 assert_eq!(
455 row_rect.w, DEFAULT_WIDTH,
456 "row should keep its fixed default width inside a wide form parent"
457 );
458 let expected_field_w =
461 DEFAULT_WIDTH - 2.0 * tokens::CONTROL_HEIGHT - 2.0 * tokens::RING_WIDTH;
462 assert!(
463 (field_rect.w - expected_field_w).abs() < 0.5,
464 "field should take leftover space inside wrapper, got {} expected ~{}",
465 field_rect.w,
466 expected_field_w,
467 );
468 }
469
470 #[test]
471 fn explicit_width_fill_still_works() {
472 let value = String::from("42");
476 let sel = Selection::default();
477 let widget =
478 numeric_input(&value, &sel, "n", NumericInputOpts::default()).width(Size::Fill(1.0));
479 let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
480 crate::widgets::form::form_control(widget),
481 ])]);
482 let mut state = UiState::new();
483 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
484 let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
485 assert!(
486 row_rect.w > DEFAULT_WIDTH,
487 "explicit `.width(Fill)` should override the fixed default, got {}",
488 row_rect.w,
489 );
490 }
491
492 fn text_event(target_key: &str, text: &str) -> UiEvent {
496 UiEvent {
497 path: None,
498 key: Some(target_key.to_string()),
499 target: Some(UiTarget {
500 key: target_key.to_string(),
501 node_id: format!("/{target_key}"),
502 rect: Rect::new(0.0, 0.0, 100.0, 32.0),
503 tooltip: None,
504 scroll_offset_y: 0.0,
505 }),
506 pointer: None,
507 key_press: None,
508 text: Some(text.to_string()),
509 selection: None,
510 modifiers: KeyModifiers::default(),
511 click_count: 0,
512 pointer_kind: None,
513 wheel_delta: None,
514 kind: UiEventKind::TextInput,
515 }
516 }
517
518 #[test]
519 fn inc_steps_value_up_by_step() {
520 let mut value = String::from("3");
521 let mut sel = Selection::default();
522 let opts = NumericInputOpts::default().step(2.0);
523 assert!(apply_event(
524 &mut value,
525 &mut sel,
526 "n",
527 &opts,
528 &click("n:inc")
529 ));
530 assert_eq!(value, "5");
531 }
532
533 #[test]
534 fn dec_steps_value_down_by_step() {
535 let mut value = String::from("3");
536 let mut sel = Selection::default();
537 let opts = NumericInputOpts::default().step(0.5).decimals(1);
538 assert!(apply_event(
539 &mut value,
540 &mut sel,
541 "n",
542 &opts,
543 &click("n:dec")
544 ));
545 assert_eq!(value, "2.5");
546 }
547
548 #[test]
549 fn inc_clamps_to_max() {
550 let mut value = String::from("99");
551 let mut sel = Selection::default();
552 let opts = NumericInputOpts::default().min(0.0).max(100.0);
553 let opts = opts.step(5.0);
555 assert!(apply_event(
556 &mut value,
557 &mut sel,
558 "n",
559 &opts,
560 &click("n:inc")
561 ));
562 assert_eq!(value, "100");
563 }
564
565 #[test]
566 fn dec_clamps_to_min() {
567 let mut value = String::from("1");
568 let mut sel = Selection::default();
569 let opts = NumericInputOpts::default().min(0.0).max(100.0);
570 assert!(apply_event(
571 &mut value,
572 &mut sel,
573 "n",
574 &opts,
575 &click("n:dec")
576 ));
577 assert_eq!(value, "0");
578 assert!(apply_event(
580 &mut value,
581 &mut sel,
582 "n",
583 &opts,
584 &click("n:dec")
585 ));
586 assert_eq!(value, "0");
587 }
588
589 #[test]
590 fn empty_value_treated_as_min_when_set() {
591 let mut value = String::new();
592 let mut sel = Selection::default();
593 let opts = NumericInputOpts::default().min(10.0).max(100.0);
594 assert!(apply_event(
596 &mut value,
597 &mut sel,
598 "n",
599 &opts,
600 &click("n:inc")
601 ));
602 assert_eq!(value, "11");
603 }
604
605 #[test]
606 fn empty_value_treated_as_zero_when_no_min() {
607 let mut value = String::new();
608 let mut sel = Selection::default();
609 let opts = NumericInputOpts::default();
610 assert!(apply_event(
611 &mut value,
612 &mut sel,
613 "n",
614 &opts,
615 &click("n:inc")
616 ));
617 assert_eq!(value, "1");
618 }
619
620 #[test]
621 fn unparseable_value_treated_as_zero_when_no_min() {
622 let mut value = String::from("abc");
623 let mut sel = Selection::default();
624 let opts = NumericInputOpts::default();
625 assert!(apply_event(
626 &mut value,
627 &mut sel,
628 "n",
629 &opts,
630 &click("n:inc")
631 ));
632 assert_eq!(value, "1");
633 }
634
635 #[test]
636 fn ignores_unrelated_keys() {
637 let mut value = String::from("3");
638 let mut sel = Selection::default();
639 let opts = NumericInputOpts::default();
640 assert!(!apply_event(
642 &mut value,
643 &mut sel,
644 "n",
645 &opts,
646 &click("other:inc")
647 ));
648 assert_eq!(value, "3");
649 }
650
651 #[test]
652 fn decimals_format_pads_zeros() {
653 let mut value = String::from("0");
654 let mut sel = Selection::default();
655 let opts = NumericInputOpts::default().step(0.10).decimals(2);
656 assert!(apply_event(
657 &mut value,
658 &mut sel,
659 "n",
660 &opts,
661 &click("n:inc")
662 ));
663 assert_eq!(value, "0.10");
664 }
665
666 #[test]
667 fn no_decimals_strips_trailing_zero() {
668 let mut value = String::from("0");
669 let mut sel = Selection::default();
670 let opts = NumericInputOpts::default().step(1.0);
671 assert!(apply_event(
672 &mut value,
673 &mut sel,
674 "n",
675 &opts,
676 &click("n:inc")
677 ));
678 assert_eq!(value, "1");
681 }
682
683 #[test]
684 fn text_event_for_other_widget_is_ignored() {
685 let mut value = String::from("42");
690 let mut sel = Selection::default();
691 let opts = NumericInputOpts::default();
692 assert!(!apply_event(
695 &mut value,
696 &mut sel,
697 "n",
698 &opts,
699 &text_event("other-input", "x"),
700 ));
701 assert_eq!(value, "42");
702 }
703
704 #[test]
705 fn text_event_filter_rejects_non_numeric_chars() {
706 let mut value = String::from("12");
710 let mut sel = Selection::default();
711 let opts = NumericInputOpts::default();
712 assert!(!apply_event(
713 &mut value,
714 &mut sel,
715 "n",
716 &opts,
717 &text_event("n:field", "abc"),
718 ));
719 assert_eq!(value, "12");
720 }
721
722 #[test]
723 fn text_event_filter_accepts_partial_numeric_states() {
724 for partial in ["-", "1.", "1.5e", "1.5e+", ".5", "+"] {
728 let mut value = String::new();
729 let mut sel = Selection::default();
730 let opts = NumericInputOpts::default();
731 assert!(
732 apply_event(
733 &mut value,
734 &mut sel,
735 "n",
736 &opts,
737 &text_event("n:field", partial),
738 ),
739 "filter should accept partial value {partial:?}",
740 );
741 assert_eq!(value, partial, "value should equal {partial:?}");
742 }
743 }
744
745 #[test]
746 fn text_event_filter_accepts_full_numeric_paste() {
747 let mut value = String::new();
748 let mut sel = Selection::default();
749 let opts = NumericInputOpts::default();
750 assert!(apply_event(
751 &mut value,
752 &mut sel,
753 "n",
754 &opts,
755 &text_event("n:field", "42.5"),
756 ));
757 assert_eq!(value, "42.5");
758 }
759
760 #[test]
761 fn build_widget_has_three_children_and_correct_keys() {
762 let value = String::from("0");
763 let sel = Selection::default();
764 let opts = NumericInputOpts::default();
765 let el = numeric_input(&value, &sel, "n", opts);
766 assert_eq!(el.key.as_deref(), Some("n"));
767 assert_eq!(el.children.len(), 3, "decrement, field, increment");
768 assert_eq!(el.children[0].key.as_deref(), Some("n:dec"));
769 assert_eq!(el.children[1].key.as_deref(), Some("n:field"));
770 assert_eq!(el.children[2].key.as_deref(), Some("n:inc"));
771 }
772
773 fn key_event(key: &str, ui_key: UiKey, modifiers: KeyModifiers) -> UiEvent {
777 use crate::event::KeyPress;
778 UiEvent {
779 path: None,
780 key: Some(key.to_string()),
781 target: Some(UiTarget {
782 key: key.to_string(),
783 node_id: format!("/{key}"),
784 rect: Rect::new(0.0, 0.0, 100.0, 32.0),
785 tooltip: None,
786 scroll_offset_y: 0.0,
787 }),
788 pointer: None,
789 key_press: Some(KeyPress {
790 key: ui_key,
791 modifiers,
792 repeat: false,
793 }),
794 text: None,
795 selection: None,
796 modifiers,
797 click_count: 0,
798 pointer_kind: None,
799 wheel_delta: None,
800 kind: UiEventKind::KeyDown,
801 }
802 }
803
804 #[test]
805 fn arrow_up_on_field_steps_up() {
806 let mut value = String::from("3");
807 let mut sel = Selection::default();
808 let opts = NumericInputOpts::default().step(1.0);
809 assert!(apply_event(
810 &mut value,
811 &mut sel,
812 "n",
813 &opts,
814 &key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
815 ));
816 assert_eq!(value, "4");
817 }
818
819 #[test]
820 fn arrow_down_on_field_steps_down() {
821 let mut value = String::from("3");
822 let mut sel = Selection::default();
823 let opts = NumericInputOpts::default().step(1.0);
824 assert!(apply_event(
825 &mut value,
826 &mut sel,
827 "n",
828 &opts,
829 &key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
830 ));
831 assert_eq!(value, "2");
832 }
833
834 #[test]
835 fn shift_arrow_steps_by_ten_times() {
836 let mut value = String::from("3");
837 let mut sel = Selection::default();
838 let opts = NumericInputOpts::default().step(1.0);
839 let shift = KeyModifiers {
840 shift: true,
841 ..KeyModifiers::default()
842 };
843 assert!(apply_event(
844 &mut value,
845 &mut sel,
846 "n",
847 &opts,
848 &key_event("n:field", UiKey::ArrowUp, shift),
849 ));
850 assert_eq!(value, "13");
851 }
852
853 #[test]
854 fn alt_arrow_steps_by_one_tenth() {
855 let mut value = String::from("0");
858 let mut sel = Selection::default();
859 let opts = NumericInputOpts::default().step(0.1).decimals(2);
860 let alt = KeyModifiers {
861 alt: true,
862 ..KeyModifiers::default()
863 };
864 assert!(apply_event(
865 &mut value,
866 &mut sel,
867 "n",
868 &opts,
869 &key_event("n:field", UiKey::ArrowUp, alt),
870 ));
871 assert_eq!(value, "0.01");
872 }
873
874 #[test]
875 fn shift_click_on_inc_button_scales_step() {
876 let mut value = String::from("3");
879 let mut sel = Selection::default();
880 let opts = NumericInputOpts::default().step(1.0);
881 let mut ev = click("n:inc");
882 ev.modifiers = KeyModifiers {
883 shift: true,
884 ..KeyModifiers::default()
885 };
886 assert!(apply_event(&mut value, &mut sel, "n", &opts, &ev));
887 assert_eq!(value, "13");
888 }
889
890 #[test]
891 fn arrow_key_on_field_clamps_to_max() {
892 let mut value = String::from("99");
893 let mut sel = Selection::default();
894 let opts = NumericInputOpts::default().step(5.0).max(100.0);
895 assert!(apply_event(
896 &mut value,
897 &mut sel,
898 "n",
899 &opts,
900 &key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
901 ));
902 assert_eq!(value, "100");
903 }
904
905 #[test]
906 fn arrow_key_routed_elsewhere_is_ignored() {
907 let mut value = String::from("3");
911 let mut sel = Selection::default();
912 let opts = NumericInputOpts::default();
913 assert!(!apply_event(
914 &mut value,
915 &mut sel,
916 "n",
917 &opts,
918 &key_event("other:field", UiKey::ArrowUp, KeyModifiers::default()),
919 ));
920 assert_eq!(value, "3");
921 }
922
923 #[test]
924 fn non_arrow_keydown_on_field_falls_through() {
925 let mut value = String::from("3");
929 let mut sel = Selection::default();
930 let opts = NumericInputOpts::default();
931 assert!(!apply_event(
932 &mut value,
933 &mut sel,
934 "n",
935 &opts,
936 &key_event("n:field", UiKey::Tab, KeyModifiers::default()),
937 ));
938 assert_eq!(value, "3");
939 }
940
941 #[test]
942 fn stacked_variant_has_field_and_chevron_column() {
943 let value = String::from("0");
944 let sel = Selection::default();
945 let opts = NumericInputOpts::default().stacked();
946 let el = numeric_input(&value, &sel, "n", opts);
947 assert_eq!(el.key.as_deref(), Some("n"));
948 assert_eq!(el.children.len(), 2, "field + chevron column");
952 assert_eq!(el.children[0].key.as_deref(), Some("n:field"));
953 let column_children = &el.children[1].children;
954 assert_eq!(column_children.len(), 2, "chevron-up over chevron-down");
955 assert_eq!(column_children[0].key.as_deref(), Some("n:inc"));
956 assert_eq!(column_children[1].key.as_deref(), Some("n:dec"));
957 }
958
959 #[test]
960 fn stacked_variant_keeps_apply_event_contract() {
961 let mut value = String::from("3");
964 let mut sel = Selection::default();
965 let opts = NumericInputOpts::default().stacked();
966 assert!(apply_event(
967 &mut value,
968 &mut sel,
969 "n",
970 &opts,
971 &click("n:inc"),
972 ));
973 assert_eq!(value, "4");
974 assert!(apply_event(
975 &mut value,
976 &mut sel,
977 "n",
978 &opts,
979 &key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
980 ));
981 assert_eq!(value, "3");
982 }
983}