fresh/view/controls/number_input/
mod.rs1mod 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::view::ui::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 disabled: Color,
365}
366
367impl Default for NumberInputColors {
368 fn default() -> Self {
369 Self {
370 label: Color::White,
371 value: Color::Yellow,
372 border: Color::Gray,
373 button: Color::Cyan,
374 focused: Color::Cyan,
375 focused_fg: Color::Black,
376 disabled: Color::DarkGray,
377 }
378 }
379}
380
381impl NumberInputColors {
382 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
384 Self {
385 label: theme.editor_fg,
386 value: theme.help_key_fg,
387 border: theme.line_number_fg,
388 button: theme.menu_active_fg,
389 focused: theme.settings_selected_bg,
390 focused_fg: theme.settings_selected_fg,
391 disabled: theme.line_number_fg,
392 }
393 }
394}
395
396#[derive(Debug, Clone, Copy, Default)]
398pub struct NumberInputLayout {
399 pub value_area: Rect,
401 pub decrement_area: Rect,
403 pub increment_area: Rect,
405 pub full_area: Rect,
407}
408
409impl NumberInputLayout {
410 pub fn is_decrement(&self, x: u16, y: u16) -> bool {
412 x >= self.decrement_area.x
413 && x < self.decrement_area.x + self.decrement_area.width
414 && y >= self.decrement_area.y
415 && y < self.decrement_area.y + self.decrement_area.height
416 }
417
418 pub fn is_increment(&self, x: u16, y: u16) -> bool {
420 x >= self.increment_area.x
421 && x < self.increment_area.x + self.increment_area.width
422 && y >= self.increment_area.y
423 && y < self.increment_area.y + self.increment_area.height
424 }
425
426 pub fn is_value(&self, x: u16, y: u16) -> bool {
428 x >= self.value_area.x
429 && x < self.value_area.x + self.value_area.width
430 && y >= self.value_area.y
431 && y < self.value_area.y + self.value_area.height
432 }
433
434 pub fn contains(&self, x: u16, y: u16) -> bool {
436 x >= self.full_area.x
437 && x < self.full_area.x + self.full_area.width
438 && y >= self.full_area.y
439 && y < self.full_area.y + self.full_area.height
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use ratatui::backend::TestBackend;
447 use ratatui::Terminal;
448
449 fn test_frame<F>(width: u16, height: u16, f: F)
450 where
451 F: FnOnce(&mut ratatui::Frame, Rect),
452 {
453 let backend = TestBackend::new(width, height);
454 let mut terminal = Terminal::new(backend).unwrap();
455 terminal
456 .draw(|frame| {
457 let area = Rect::new(0, 0, width, height);
458 f(frame, area);
459 })
460 .unwrap();
461 }
462
463 #[test]
464 fn test_number_input_renders() {
465 test_frame(40, 1, |frame, area| {
466 let state = NumberInputState::new(42, "Count");
467 let colors = NumberInputColors::default();
468 let layout = render_number_input(frame, area, &state, &colors);
469
470 assert!(layout.value_area.width > 0);
471 assert!(layout.decrement_area.width > 0);
472 assert!(layout.increment_area.width > 0);
473 });
474 }
475
476 #[test]
477 fn test_number_input_increment() {
478 let mut state = NumberInputState::new(5, "Value");
479 state.increment();
480 assert_eq!(state.value, 6);
481 }
482
483 #[test]
484 fn test_number_input_decrement() {
485 let mut state = NumberInputState::new(5, "Value");
486 state.decrement();
487 assert_eq!(state.value, 4);
488 }
489
490 #[test]
491 fn test_number_input_min_max() {
492 let mut state = NumberInputState::new(5, "Value").with_min(0).with_max(10);
493
494 state.set_value(-5);
495 assert_eq!(state.value, 0);
496
497 state.set_value(20);
498 assert_eq!(state.value, 10);
499 }
500
501 #[test]
502 fn test_number_input_step() {
503 let mut state = NumberInputState::new(0, "Value").with_step(5);
504 state.increment();
505 assert_eq!(state.value, 5);
506 state.increment();
507 assert_eq!(state.value, 10);
508 }
509
510 #[test]
511 fn test_number_input_disabled() {
512 let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
513 state.increment();
514 assert_eq!(state.value, 5);
515 }
516
517 #[test]
518 fn test_number_input_hit_detection() {
519 test_frame(40, 1, |frame, area| {
520 let state = NumberInputState::new(42, "Count");
521 let colors = NumberInputColors::default();
522 let layout = render_number_input(frame, area, &state, &colors);
523
524 let dec_x = layout.decrement_area.x;
525 assert!(layout.is_decrement(dec_x, 0));
526 assert!(!layout.is_increment(dec_x, 0));
527
528 let inc_x = layout.increment_area.x;
529 assert!(layout.is_increment(inc_x, 0));
530 assert!(!layout.is_decrement(inc_x, 0));
531 });
532 }
533
534 #[test]
535 fn test_number_input_start_editing() {
536 let mut state = NumberInputState::new(42, "Value");
537 assert!(!state.editing());
538 assert_eq!(state.display_text(), "42");
539
540 state.start_editing();
541 assert!(state.editing());
542 assert_eq!(state.display_text(), "42");
543 }
544
545 #[test]
546 fn test_number_input_cancel_editing() {
547 let mut state = NumberInputState::new(42, "Value");
548 state.start_editing();
549 state.insert_char('1');
551 state.insert_char('0');
552 state.insert_char('0');
553 assert_eq!(state.display_text(), "100");
554
555 state.cancel_editing();
556 assert!(!state.editing());
557 assert_eq!(state.display_text(), "42");
558 assert_eq!(state.value, 42);
559 }
560
561 #[test]
562 fn test_number_input_confirm_editing() {
563 let mut state = NumberInputState::new(42, "Value");
564 state.start_editing();
565 state.select_all();
567 state.insert_str("100");
568
569 state.confirm_editing();
570 assert!(!state.editing());
571 assert_eq!(state.value, 100);
572 }
573
574 #[test]
575 fn test_number_input_confirm_invalid_resets() {
576 let mut state = NumberInputState::new(42, "Value");
577 state.start_editing();
578 state.select_all();
580 state.insert_str("abc"); state.confirm_editing();
583 assert!(!state.editing());
584 assert_eq!(state.value, 42);
586 }
587
588 #[test]
589 fn test_number_input_insert_char() {
590 let mut state = NumberInputState::new(0, "Value");
591 state.start_editing();
592 state.select_all();
594 state.insert_char('1');
595 state.insert_char('2');
596 state.insert_char('3');
597 assert_eq!(state.display_text(), "123");
598
599 let mut state2 = NumberInputState::new(0, "Value");
600 state2.start_editing();
601 state2.select_all();
602 state2.insert_char('-');
603 assert_eq!(state2.display_text(), "-");
604 state2.insert_char('-'); state2.insert_char('5');
606 assert_eq!(state2.display_text(), "--5");
607 }
608
609 #[test]
610 fn test_number_input_backspace() {
611 let mut state = NumberInputState::new(123, "Value");
612 state.start_editing();
613 assert_eq!(state.display_text(), "123");
614
615 state.move_end();
617
618 state.backspace();
619 assert_eq!(state.display_text(), "12");
620 state.backspace();
621 assert_eq!(state.display_text(), "1");
622 state.backspace();
623 assert_eq!(state.display_text(), "");
624 state.backspace();
625 assert_eq!(state.display_text(), "");
626 }
627
628 #[test]
629 fn test_number_input_display_text() {
630 let mut state = NumberInputState::new(42, "Value");
631
632 assert_eq!(state.display_text(), "42");
633
634 state.start_editing();
635 assert_eq!(state.display_text(), "42");
636 state.move_end();
638 state.insert_char('0');
639 assert_eq!(state.display_text(), "420");
640 }
641
642 #[test]
643 fn test_number_input_editing_respects_minmax() {
644 let mut state = NumberInputState::new(50, "Value").with_min(0).with_max(100);
645 state.start_editing();
646 state.select_all();
647 state.insert_str("200");
648
649 state.confirm_editing();
650 assert_eq!(state.value, 100);
651 }
652
653 #[test]
654 fn test_number_input_disabled_no_editing() {
655 let mut state = NumberInputState::new(42, "Value").with_focus(FocusState::Disabled);
656 state.start_editing();
657 assert!(!state.editing());
658 }
659
660 #[test]
661 fn test_number_input_decimal_point() {
662 let mut state = NumberInputState::new(0, "Value");
663 state.start_editing();
664 state.select_all();
665 state.insert_str("0.25");
666 assert_eq!(state.display_text(), "0.25");
667
668 state.confirm_editing();
670 assert_eq!(state.value, 0);
671 }
672
673 #[test]
674 fn test_number_input_selection() {
675 let mut state = NumberInputState::new(12345, "Value");
676 state.start_editing();
677 assert_eq!(state.display_text(), "12345");
678
679 state.select_all();
681 assert!(state.has_selection());
682 state.insert_char('9');
683 assert_eq!(state.display_text(), "9");
684 }
685
686 #[test]
687 fn test_number_input_cursor_navigation() {
688 let mut state = NumberInputState::new(123, "Value");
689 state.start_editing();
690 assert_eq!(state.cursor_col(), 3);
692
693 state.move_left();
694 assert_eq!(state.cursor_col(), 2);
695
696 state.move_home();
697 assert_eq!(state.cursor_col(), 0);
698
699 state.move_end();
700 assert_eq!(state.cursor_col(), 3);
701 }
702}