1mod input;
13mod render;
14
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17
18pub use input::NumberInputEvent;
19pub use render::{render_number_input, render_number_input_aligned};
20
21use super::FocusState;
22use crate::primitives::text_edit::TextEdit;
23
24#[derive(Debug, Clone)]
26pub struct NumberInputState {
27 pub value: i64,
29 pub min: Option<i64>,
31 pub max: Option<i64>,
33 pub step: i64,
35 pub label: String,
37 pub focus: FocusState,
39 pub editor: Option<TextEdit>,
41 pub is_percentage: bool,
44}
45
46impl NumberInputState {
47 pub fn new(value: i64, label: impl Into<String>) -> Self {
49 Self {
50 value,
51 min: None,
52 max: None,
53 step: 1,
54 label: label.into(),
55 focus: FocusState::Normal,
56 editor: None,
57 is_percentage: false,
58 }
59 }
60
61 pub fn editing(&self) -> bool {
63 self.editor.is_some()
64 }
65
66 pub fn with_min(mut self, min: i64) -> Self {
68 self.min = Some(min);
69 self
70 }
71
72 pub fn with_max(mut self, max: i64) -> Self {
74 self.max = Some(max);
75 self
76 }
77
78 pub fn with_step(mut self, step: i64) -> Self {
80 self.step = step;
81 self
82 }
83
84 pub fn with_focus(mut self, focus: FocusState) -> Self {
86 self.focus = focus;
87 self
88 }
89
90 pub fn with_percentage(mut self) -> Self {
92 self.is_percentage = true;
93 self
94 }
95
96 pub fn is_enabled(&self) -> bool {
98 self.focus != FocusState::Disabled
99 }
100
101 pub fn increment(&mut self) {
103 if !self.is_enabled() {
104 return;
105 }
106 let new_value = self.value.saturating_add(self.step);
107 self.value = match self.max {
108 Some(max) => new_value.min(max),
109 None => new_value,
110 };
111 }
112
113 pub fn decrement(&mut self) {
115 if !self.is_enabled() {
116 return;
117 }
118 let new_value = self.value.saturating_sub(self.step);
119 self.value = match self.min {
120 Some(min) => new_value.max(min),
121 None => new_value,
122 };
123 }
124
125 pub fn set_value(&mut self, value: i64) {
127 if !self.is_enabled() {
128 return;
129 }
130 let mut v = value;
131 if let Some(min) = self.min {
132 v = v.max(min);
133 }
134 if let Some(max) = self.max {
135 v = v.min(max);
136 }
137 self.value = v;
138 }
139
140 pub fn start_editing(&mut self) {
142 if !self.is_enabled() {
143 return;
144 }
145 let mut editor = TextEdit::single_line();
146 editor.set_value(&self.value.to_string());
147 editor.select_all();
149 self.editor = Some(editor);
150 }
151
152 pub fn cancel_editing(&mut self) {
154 self.editor = None;
155 }
156
157 pub fn confirm_editing(&mut self) {
159 if let Some(editor) = self.editor.take() {
160 if let Ok(new_value) = editor.value().parse::<i64>() {
161 self.set_value(new_value);
162 }
163 }
164 }
165
166 pub fn insert_char(&mut self, c: char) {
169 if let Some(editor) = &mut self.editor {
170 if c.is_ascii_digit() || c == '-' || c == '.' {
172 editor.insert_char(c);
173 }
174 }
175 }
176
177 pub fn backspace(&mut self) {
179 if let Some(editor) = &mut self.editor {
180 editor.backspace();
181 }
182 }
183
184 pub fn delete(&mut self) {
186 if let Some(editor) = &mut self.editor {
187 editor.delete();
188 }
189 }
190
191 pub fn move_left(&mut self) {
193 if let Some(editor) = &mut self.editor {
194 editor.move_left();
195 }
196 }
197
198 pub fn move_right(&mut self) {
200 if let Some(editor) = &mut self.editor {
201 editor.move_right();
202 }
203 }
204
205 pub fn move_home(&mut self) {
207 if let Some(editor) = &mut self.editor {
208 editor.move_home();
209 }
210 }
211
212 pub fn move_end(&mut self) {
214 if let Some(editor) = &mut self.editor {
215 editor.move_end();
216 }
217 }
218
219 pub fn move_word_left(&mut self) {
221 if let Some(editor) = &mut self.editor {
222 editor.move_word_left();
223 }
224 }
225
226 pub fn move_word_right(&mut self) {
228 if let Some(editor) = &mut self.editor {
229 editor.move_word_right();
230 }
231 }
232
233 pub fn move_left_selecting(&mut self) {
235 if let Some(editor) = &mut self.editor {
236 editor.move_left_selecting();
237 }
238 }
239
240 pub fn move_right_selecting(&mut self) {
242 if let Some(editor) = &mut self.editor {
243 editor.move_right_selecting();
244 }
245 }
246
247 pub fn move_home_selecting(&mut self) {
249 if let Some(editor) = &mut self.editor {
250 editor.move_home_selecting();
251 }
252 }
253
254 pub fn move_end_selecting(&mut self) {
256 if let Some(editor) = &mut self.editor {
257 editor.move_end_selecting();
258 }
259 }
260
261 pub fn move_word_left_selecting(&mut self) {
263 if let Some(editor) = &mut self.editor {
264 editor.move_word_left_selecting();
265 }
266 }
267
268 pub fn move_word_right_selecting(&mut self) {
270 if let Some(editor) = &mut self.editor {
271 editor.move_word_right_selecting();
272 }
273 }
274
275 pub fn select_all(&mut self) {
277 if let Some(editor) = &mut self.editor {
278 editor.select_all();
279 }
280 }
281
282 pub fn delete_word_forward(&mut self) {
284 if let Some(editor) = &mut self.editor {
285 editor.delete_word_forward();
286 }
287 }
288
289 pub fn delete_word_backward(&mut self) {
291 if let Some(editor) = &mut self.editor {
292 editor.delete_word_backward();
293 }
294 }
295
296 pub fn selected_text(&self) -> Option<String> {
298 self.editor.as_ref().and_then(|e| e.selected_text())
299 }
300
301 pub fn delete_selection(&mut self) -> Option<String> {
303 self.editor.as_mut().and_then(|e| e.delete_selection())
304 }
305
306 pub fn insert_str(&mut self, text: &str) {
308 if let Some(editor) = &mut self.editor {
309 let filtered: String = text
311 .chars()
312 .filter(|c| c.is_ascii_digit() || *c == '-' || *c == '.')
313 .collect();
314 editor.insert_str(&filtered);
315 }
316 }
317
318 pub fn display_text(&self) -> String {
320 if let Some(editor) = &self.editor {
321 editor.value()
322 } else {
323 self.value.to_string()
324 }
325 }
326
327 pub fn cursor_col(&self) -> usize {
329 self.editor.as_ref().map(|e| e.cursor_col).unwrap_or(0)
330 }
331
332 pub fn has_selection(&self) -> bool {
334 self.editor
335 .as_ref()
336 .map(|e| e.has_selection())
337 .unwrap_or(false)
338 }
339
340 pub fn selection_range(&self) -> Option<(usize, usize)> {
342 self.editor.as_ref().and_then(|e| {
343 e.selection_range()
344 .map(|((_, start_col), (_, end_col))| (start_col, end_col))
345 })
346 }
347}
348
349#[derive(Debug, Clone, Copy)]
351pub struct NumberInputColors {
352 pub label: Color,
354 pub value: Color,
356 pub border: Color,
358 pub button: Color,
360 pub focused: Color,
362 pub focused_fg: Color,
364 pub selection_bg: Color,
368 pub disabled: Color,
370}
371
372impl Default for NumberInputColors {
373 fn default() -> Self {
374 Self {
375 label: Color::White,
376 value: Color::Yellow,
377 border: Color::Gray,
378 button: Color::Cyan,
379 focused: Color::Cyan,
380 focused_fg: Color::Black,
381 selection_bg: Color::Blue,
382 disabled: Color::DarkGray,
383 }
384 }
385}
386
387impl NumberInputColors {
388 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
390 Self {
391 label: theme.editor_fg,
392 value: theme.help_key_fg,
393 border: theme.line_number_fg,
394 button: theme.menu_active_fg,
395 focused: theme.settings_selected_bg,
396 focused_fg: theme.settings_selected_fg,
397 selection_bg: theme.selection_bg,
400 disabled: theme.line_number_fg,
401 }
402 }
403}
404
405#[derive(Debug, Clone, Copy, Default)]
407pub struct NumberInputLayout {
408 pub value_area: Rect,
410 pub decrement_area: Rect,
414 pub increment_area: Rect,
416 pub full_area: Rect,
418}
419
420impl NumberInputLayout {
421 pub fn is_decrement(&self, x: u16, y: u16) -> bool {
423 x >= self.decrement_area.x
424 && x < self.decrement_area.x + self.decrement_area.width
425 && y >= self.decrement_area.y
426 && y < self.decrement_area.y + self.decrement_area.height
427 }
428
429 pub fn is_increment(&self, x: u16, y: u16) -> bool {
431 x >= self.increment_area.x
432 && x < self.increment_area.x + self.increment_area.width
433 && y >= self.increment_area.y
434 && y < self.increment_area.y + self.increment_area.height
435 }
436
437 pub fn is_value(&self, x: u16, y: u16) -> bool {
439 x >= self.value_area.x
440 && x < self.value_area.x + self.value_area.width
441 && y >= self.value_area.y
442 && y < self.value_area.y + self.value_area.height
443 }
444
445 pub fn contains(&self, x: u16, y: u16) -> bool {
447 x >= self.full_area.x
448 && x < self.full_area.x + self.full_area.width
449 && y >= self.full_area.y
450 && y < self.full_area.y + self.full_area.height
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use ratatui::backend::TestBackend;
458 use ratatui::Terminal;
459
460 fn test_frame<F>(width: u16, height: u16, f: F)
461 where
462 F: FnOnce(&mut ratatui::Frame, Rect),
463 {
464 let backend = TestBackend::new(width, height);
465 let mut terminal = Terminal::new(backend).unwrap();
466 terminal
467 .draw(|frame| {
468 let area = Rect::new(0, 0, width, height);
469 f(frame, area);
470 })
471 .unwrap();
472 }
473
474 #[test]
475 fn test_number_input_renders() {
476 test_frame(40, 1, |frame, area| {
477 let state = NumberInputState::new(42, "Count");
478 let colors = NumberInputColors::default();
479 let layout = render_number_input(frame, area, &state, &colors);
480
481 assert!(layout.value_area.width > 0);
482 assert_eq!(layout.decrement_area.width, 0);
484 assert_eq!(layout.increment_area.width, 0);
485 });
486 }
487
488 #[test]
489 fn test_number_input_increment() {
490 let mut state = NumberInputState::new(5, "Value");
491 state.increment();
492 assert_eq!(state.value, 6);
493 }
494
495 #[test]
496 fn test_number_input_decrement() {
497 let mut state = NumberInputState::new(5, "Value");
498 state.decrement();
499 assert_eq!(state.value, 4);
500 }
501
502 #[test]
503 fn test_number_input_min_max() {
504 let mut state = NumberInputState::new(5, "Value").with_min(0).with_max(10);
505
506 state.set_value(-5);
507 assert_eq!(state.value, 0);
508
509 state.set_value(20);
510 assert_eq!(state.value, 10);
511 }
512
513 #[test]
514 fn test_number_input_step() {
515 let mut state = NumberInputState::new(0, "Value").with_step(5);
516 state.increment();
517 assert_eq!(state.value, 5);
518 state.increment();
519 assert_eq!(state.value, 10);
520 }
521
522 #[test]
523 fn test_number_input_disabled() {
524 let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
525 state.increment();
526 assert_eq!(state.value, 5);
527 }
528
529 #[test]
530 fn test_number_input_hit_detection() {
531 test_frame(40, 1, |frame, area| {
532 let state = NumberInputState::new(42, "Count");
533 let colors = NumberInputColors::default();
534 let layout = render_number_input(frame, area, &state, &colors);
535
536 let val_x = layout.value_area.x;
538 assert!(layout.is_value(val_x, 0));
539 assert!(!layout.is_increment(val_x, 0));
540 assert!(!layout.is_decrement(val_x, 0));
541 });
542 }
543
544 #[test]
545 fn test_number_input_start_editing() {
546 let mut state = NumberInputState::new(42, "Value");
547 assert!(!state.editing());
548 assert_eq!(state.display_text(), "42");
549
550 state.start_editing();
551 assert!(state.editing());
552 assert_eq!(state.display_text(), "42");
553 }
554
555 #[test]
556 fn test_number_input_cancel_editing() {
557 let mut state = NumberInputState::new(42, "Value");
558 state.start_editing();
559 state.insert_char('1');
561 state.insert_char('0');
562 state.insert_char('0');
563 assert_eq!(state.display_text(), "100");
564
565 state.cancel_editing();
566 assert!(!state.editing());
567 assert_eq!(state.display_text(), "42");
568 assert_eq!(state.value, 42);
569 }
570
571 #[test]
572 fn test_number_input_confirm_editing() {
573 let mut state = NumberInputState::new(42, "Value");
574 state.start_editing();
575 state.select_all();
577 state.insert_str("100");
578
579 state.confirm_editing();
580 assert!(!state.editing());
581 assert_eq!(state.value, 100);
582 }
583
584 #[test]
585 fn test_number_input_confirm_invalid_resets() {
586 let mut state = NumberInputState::new(42, "Value");
587 state.start_editing();
588 state.select_all();
590 state.insert_str("abc"); state.confirm_editing();
593 assert!(!state.editing());
594 assert_eq!(state.value, 42);
596 }
597
598 #[test]
599 fn test_number_input_insert_char() {
600 let mut state = NumberInputState::new(0, "Value");
601 state.start_editing();
602 state.select_all();
604 state.insert_char('1');
605 state.insert_char('2');
606 state.insert_char('3');
607 assert_eq!(state.display_text(), "123");
608
609 let mut state2 = NumberInputState::new(0, "Value");
610 state2.start_editing();
611 state2.select_all();
612 state2.insert_char('-');
613 assert_eq!(state2.display_text(), "-");
614 state2.insert_char('-'); state2.insert_char('5');
616 assert_eq!(state2.display_text(), "--5");
617 }
618
619 #[test]
620 fn test_number_input_backspace() {
621 let mut state = NumberInputState::new(123, "Value");
622 state.start_editing();
623 assert_eq!(state.display_text(), "123");
624
625 state.move_end();
627
628 state.backspace();
629 assert_eq!(state.display_text(), "12");
630 state.backspace();
631 assert_eq!(state.display_text(), "1");
632 state.backspace();
633 assert_eq!(state.display_text(), "");
634 state.backspace();
635 assert_eq!(state.display_text(), "");
636 }
637
638 #[test]
639 fn test_number_input_display_text() {
640 let mut state = NumberInputState::new(42, "Value");
641
642 assert_eq!(state.display_text(), "42");
643
644 state.start_editing();
645 assert_eq!(state.display_text(), "42");
646 state.move_end();
648 state.insert_char('0');
649 assert_eq!(state.display_text(), "420");
650 }
651
652 #[test]
653 fn test_number_input_editing_respects_minmax() {
654 let mut state = NumberInputState::new(50, "Value").with_min(0).with_max(100);
655 state.start_editing();
656 state.select_all();
657 state.insert_str("200");
658
659 state.confirm_editing();
660 assert_eq!(state.value, 100);
661 }
662
663 #[test]
664 fn test_number_input_disabled_no_editing() {
665 let mut state = NumberInputState::new(42, "Value").with_focus(FocusState::Disabled);
666 state.start_editing();
667 assert!(!state.editing());
668 }
669
670 #[test]
671 fn test_number_input_decimal_point() {
672 let mut state = NumberInputState::new(0, "Value");
673 state.start_editing();
674 state.select_all();
675 state.insert_str("0.25");
676 assert_eq!(state.display_text(), "0.25");
677
678 state.confirm_editing();
680 assert_eq!(state.value, 0);
681 }
682
683 #[test]
684 fn test_number_input_selection() {
685 let mut state = NumberInputState::new(12345, "Value");
686 state.start_editing();
687 assert_eq!(state.display_text(), "12345");
688
689 state.select_all();
691 assert!(state.has_selection());
692 state.insert_char('9');
693 assert_eq!(state.display_text(), "9");
694 }
695
696 #[test]
697 fn test_number_input_cursor_navigation() {
698 let mut state = NumberInputState::new(123, "Value");
699 state.start_editing();
700 assert_eq!(state.cursor_col(), 3);
702
703 state.move_left();
704 assert_eq!(state.cursor_col(), 2);
705
706 state.move_home();
707 assert_eq!(state.cursor_col(), 0);
708
709 state.move_end();
710 assert_eq!(state.cursor_col(), 3);
711 }
712
713 #[test]
718 fn test_value_cell_width_stable_between_edit_and_view() {
719 fn bracket_columns(state: &NumberInputState) -> (u16, u16) {
720 let backend = TestBackend::new(40, 1);
721 let mut terminal = Terminal::new(backend).unwrap();
722 terminal
723 .draw(|frame| {
724 let area = Rect::new(0, 0, 40, 1);
725 let colors = NumberInputColors::default();
726 render_number_input(frame, area, state, &colors);
727 })
728 .unwrap();
729 let buffer = terminal.backend().buffer().clone();
730 let mut open = None;
731 let mut close = None;
732 for x in 0..40 {
733 let symbol = buffer.cell((x, 0)).map(|c| c.symbol()).unwrap_or("");
734 if symbol == "[" && open.is_none() {
735 open = Some(x);
736 } else if symbol == "]" && open.is_some() && close.is_none() {
737 close = Some(x);
738 }
739 }
740 (
741 open.expect("missing opening bracket"),
742 close.expect("missing closing bracket"),
743 )
744 }
745
746 let view_state = NumberInputState::new(4, "Tab Size");
747 let mut edit_state = NumberInputState::new(4, "Tab Size");
748 edit_state.start_editing();
749
750 let view_brackets = bracket_columns(&view_state);
751 let edit_brackets = bracket_columns(&edit_state);
752 assert_eq!(
753 view_brackets, edit_brackets,
754 "value cell brackets must stay at the same columns when entering edit mode"
755 );
756 }
757
758 #[test]
764 fn test_digit_column_stable_across_view_select_and_typing() {
765 fn digit_column(state: &NumberInputState, digit: char) -> u16 {
766 let backend = TestBackend::new(40, 1);
767 let mut terminal = Terminal::new(backend).unwrap();
768 terminal
769 .draw(|frame| {
770 let area = Rect::new(0, 0, 40, 1);
771 let colors = NumberInputColors::default();
772 render_number_input(frame, area, state, &colors);
773 })
774 .unwrap();
775 let buffer = terminal.backend().buffer().clone();
776 let needle = digit.to_string();
777 for x in 0..40 {
778 let symbol = buffer.cell((x, 0)).map(|c| c.symbol()).unwrap_or("");
779 if symbol == needle {
780 return x;
781 }
782 }
783 panic!("digit {digit:?} not found on rendered line");
784 }
785
786 let view_state = NumberInputState::new(4, "Tab Size");
789
790 let mut select_state = NumberInputState::new(4, "Tab Size");
792 select_state.start_editing();
793
794 let mut typed_state = NumberInputState::new(4, "Tab Size");
797 typed_state.start_editing();
798 typed_state.insert_char('1');
799
800 let view_col = digit_column(&view_state, '4');
801 let select_col = digit_column(&select_state, '4');
802 let typed_col = digit_column(&typed_state, '1');
803
804 assert_eq!(
805 view_col, select_col,
806 "digit must stay at the same column when entering edit mode"
807 );
808 assert_eq!(
809 view_col, typed_col,
810 "digit must stay at the same column after typing replaces the selection"
811 );
812 }
813
814 #[test]
819 fn test_selection_bg_distinct_from_focus_bg() {
820 let theme = crate::view::theme::Theme::load_builtin("dark")
821 .or_else(|| crate::view::theme::Theme::load_builtin("default"))
822 .expect("expected a builtin theme to load");
823 let colors = NumberInputColors::from_theme(&theme);
824 assert_ne!(
825 colors.selection_bg, colors.focused,
826 "selection bg must differ from focus bg, otherwise the in-edit selection is invisible \
827 when the row is focused"
828 );
829 }
830}