Skip to main content

fresh/view/controls/number_input/
mod.rs

1//! Number input control with increment/decrement
2//!
3//! Renders as: `Label: [  42  ] [-] [+]`
4//!
5//! This module provides a complete number input component with:
6//! - State management (`NumberInputState`)
7//! - Rendering (`render_number_input`, `render_number_input_aligned`)
8//! - Input handling (`NumberInputState::handle_mouse`, `handle_key`)
9//! - Layout/hit testing (`NumberInputLayout`)
10
11mod 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/// State for a number input control
24#[derive(Debug, Clone)]
25pub struct NumberInputState {
26    /// Current value
27    pub value: i64,
28    /// Minimum allowed value
29    pub min: Option<i64>,
30    /// Maximum allowed value
31    pub max: Option<i64>,
32    /// Step amount for increment/decrement
33    pub step: i64,
34    /// Label displayed before the input
35    pub label: String,
36    /// Focus state
37    pub focus: FocusState,
38    /// Text editor for editing mode (None when not editing)
39    pub editor: Option<TextEdit>,
40    /// Whether this value is a percentage (float value * 100 for display)
41    /// When true, the value should be divided by 100 when converting back to JSON
42    pub is_percentage: bool,
43}
44
45impl NumberInputState {
46    /// Create a new number input state
47    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    /// Check if currently editing
61    pub fn editing(&self) -> bool {
62        self.editor.is_some()
63    }
64
65    /// Set the minimum value
66    pub fn with_min(mut self, min: i64) -> Self {
67        self.min = Some(min);
68        self
69    }
70
71    /// Set the maximum value
72    pub fn with_max(mut self, max: i64) -> Self {
73        self.max = Some(max);
74        self
75    }
76
77    /// Set the step amount
78    pub fn with_step(mut self, step: i64) -> Self {
79        self.step = step;
80        self
81    }
82
83    /// Set the focus state
84    pub fn with_focus(mut self, focus: FocusState) -> Self {
85        self.focus = focus;
86        self
87    }
88
89    /// Mark this value as a percentage (float * 100 for display)
90    pub fn with_percentage(mut self) -> Self {
91        self.is_percentage = true;
92        self
93    }
94
95    /// Check if the control is enabled
96    pub fn is_enabled(&self) -> bool {
97        self.focus != FocusState::Disabled
98    }
99
100    /// Increment the value by step
101    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    /// Decrement the value by step
113    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    /// Set the value directly, respecting min/max
125    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    /// Start editing mode
140    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        // Select all text so typing replaces the value
147        editor.select_all();
148        self.editor = Some(editor);
149    }
150
151    /// Cancel editing and restore original value
152    pub fn cancel_editing(&mut self) {
153        self.editor = None;
154    }
155
156    /// Confirm editing and apply the new value
157    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    /// Insert a character while editing
166    /// Allows digits, minus sign, and decimal point for number input
167    pub fn insert_char(&mut self, c: char) {
168        if let Some(editor) = &mut self.editor {
169            // Allow digits, minus sign, and decimal point
170            if c.is_ascii_digit() || c == '-' || c == '.' {
171                editor.insert_char(c);
172            }
173        }
174    }
175
176    /// Backspace while editing
177    pub fn backspace(&mut self) {
178        if let Some(editor) = &mut self.editor {
179            editor.backspace();
180        }
181    }
182
183    /// Delete character at cursor
184    pub fn delete(&mut self) {
185        if let Some(editor) = &mut self.editor {
186            editor.delete();
187        }
188    }
189
190    /// Move cursor left
191    pub fn move_left(&mut self) {
192        if let Some(editor) = &mut self.editor {
193            editor.move_left();
194        }
195    }
196
197    /// Move cursor right
198    pub fn move_right(&mut self) {
199        if let Some(editor) = &mut self.editor {
200            editor.move_right();
201        }
202    }
203
204    /// Move cursor to start of text
205    pub fn move_home(&mut self) {
206        if let Some(editor) = &mut self.editor {
207            editor.move_home();
208        }
209    }
210
211    /// Move cursor to end of text
212    pub fn move_end(&mut self) {
213        if let Some(editor) = &mut self.editor {
214            editor.move_end();
215        }
216    }
217
218    /// Move cursor left by word (Ctrl+Left)
219    pub fn move_word_left(&mut self) {
220        if let Some(editor) = &mut self.editor {
221            editor.move_word_left();
222        }
223    }
224
225    /// Move cursor right by word (Ctrl+Right)
226    pub fn move_word_right(&mut self) {
227        if let Some(editor) = &mut self.editor {
228            editor.move_word_right();
229        }
230    }
231
232    /// Move cursor left with selection (Shift+Left)
233    pub fn move_left_selecting(&mut self) {
234        if let Some(editor) = &mut self.editor {
235            editor.move_left_selecting();
236        }
237    }
238
239    /// Move cursor right with selection (Shift+Right)
240    pub fn move_right_selecting(&mut self) {
241        if let Some(editor) = &mut self.editor {
242            editor.move_right_selecting();
243        }
244    }
245
246    /// Move to start with selection (Shift+Home)
247    pub fn move_home_selecting(&mut self) {
248        if let Some(editor) = &mut self.editor {
249            editor.move_home_selecting();
250        }
251    }
252
253    /// Move to end with selection (Shift+End)
254    pub fn move_end_selecting(&mut self) {
255        if let Some(editor) = &mut self.editor {
256            editor.move_end_selecting();
257        }
258    }
259
260    /// Move word left with selection (Ctrl+Shift+Left)
261    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    /// Move word right with selection (Ctrl+Shift+Right)
268    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    /// Select all text (Ctrl+A)
275    pub fn select_all(&mut self) {
276        if let Some(editor) = &mut self.editor {
277            editor.select_all();
278        }
279    }
280
281    /// Delete from cursor to end of word (Ctrl+Delete)
282    pub fn delete_word_forward(&mut self) {
283        if let Some(editor) = &mut self.editor {
284            editor.delete_word_forward();
285        }
286    }
287
288    /// Delete from start of word to cursor (Ctrl+Backspace)
289    pub fn delete_word_backward(&mut self) {
290        if let Some(editor) = &mut self.editor {
291            editor.delete_word_backward();
292        }
293    }
294
295    /// Get selected text for copy
296    pub fn selected_text(&self) -> Option<String> {
297        self.editor.as_ref().and_then(|e| e.selected_text())
298    }
299
300    /// Delete selection and return deleted text (for cut)
301    pub fn delete_selection(&mut self) -> Option<String> {
302        self.editor.as_mut().and_then(|e| e.delete_selection())
303    }
304
305    /// Insert string at cursor (for paste)
306    pub fn insert_str(&mut self, text: &str) {
307        if let Some(editor) = &mut self.editor {
308            // Filter to only allow valid number characters
309            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    /// Get the display text (edit text when editing, value otherwise)
318    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    /// Get cursor position when editing (column in single-line text)
327    pub fn cursor_col(&self) -> usize {
328        self.editor.as_ref().map(|e| e.cursor_col).unwrap_or(0)
329    }
330
331    /// Check if there's an active selection
332    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    /// Get selection range as (start, end) column positions
340    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/// Colors for the number input control
349#[derive(Debug, Clone, Copy)]
350pub struct NumberInputColors {
351    /// Label color
352    pub label: Color,
353    /// Value text color
354    pub value: Color,
355    /// Border/bracket color
356    pub border: Color,
357    /// Button color (increment/decrement)
358    pub button: Color,
359    /// Focused highlight background color
360    pub focused: Color,
361    /// Focused highlight foreground color (text on focused background)
362    pub focused_fg: Color,
363    /// Disabled color
364    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    /// Create colors from theme
383    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/// Layout information returned after rendering for hit testing
397#[derive(Debug, Clone, Copy, Default)]
398pub struct NumberInputLayout {
399    /// The value display area
400    pub value_area: Rect,
401    /// The decrement button area
402    pub decrement_area: Rect,
403    /// The increment button area
404    pub increment_area: Rect,
405    /// The full control area
406    pub full_area: Rect,
407}
408
409impl NumberInputLayout {
410    /// Check if a point is on the decrement button
411    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    /// Check if a point is on the increment button
419    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    /// Check if a point is on the value area
427    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    /// Check if a point is within any part of the control
435    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        // After start_editing, text is selected so typing replaces it
550        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        // Clear and type new value
566        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        // Type invalid text - only valid chars will be inserted
579        state.select_all();
580        state.insert_str("abc"); // This will be filtered to empty
581
582        state.confirm_editing();
583        assert!(!state.editing());
584        // Value remains unchanged since empty string can't be parsed
585        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        // Clear and insert new chars
593        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('-'); // Multiple minus signs allowed by TextEdit
605        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        // After start_editing, text is selected. Move to end to deselect.
616        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        // After start_editing, text is selected. Move to end to append.
637        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        // Confirm won't parse as i64, so value stays at 0
669        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        // Select all and replace
680        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        // Cursor starts at end
691        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}