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