Skip to main content

fresh/view/controls/number_input/
input.rs

1//! Number input handling
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4
5use super::{FocusState, NumberInputLayout, NumberInputState};
6
7/// Events that can be returned from number input handling
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum NumberInputEvent {
10    /// Value was incremented
11    Incremented(i64),
12    /// Value was decremented
13    Decremented(i64),
14    /// Value was changed (after editing confirmed)
15    Changed(i64),
16    /// Started editing mode
17    StartedEditing,
18    /// Cancelled editing
19    CancelledEditing,
20    /// Mouse is hovering over the control
21    Hovered,
22    /// Mouse left the control area
23    Left,
24}
25
26impl NumberInputState {
27    /// Handle a mouse event for this number input
28    ///
29    /// # Arguments
30    /// * `event` - The mouse event to handle
31    /// * `layout` - The control's rendered layout for hit testing
32    ///
33    /// # Returns
34    /// * `Some(NumberInputEvent)` if the event was consumed
35    /// * `None` if the event was not relevant
36    pub fn handle_mouse(
37        &mut self,
38        event: MouseEvent,
39        layout: &NumberInputLayout,
40    ) -> Option<NumberInputEvent> {
41        if !self.is_enabled() {
42            return None;
43        }
44
45        match event.kind {
46            MouseEventKind::Down(MouseButton::Left) => {
47                if layout.is_value(event.column, event.row) {
48                    if !self.editing() {
49                        self.start_editing();
50                        Some(NumberInputEvent::StartedEditing)
51                    } else {
52                        None
53                    }
54                } else {
55                    None
56                }
57            }
58            MouseEventKind::Moved => {
59                let inside = layout.contains(event.column, event.row);
60                if inside {
61                    if self.focus != FocusState::Focused {
62                        self.focus = FocusState::Hovered;
63                    }
64                    Some(NumberInputEvent::Hovered)
65                } else if self.focus == FocusState::Hovered {
66                    self.focus = FocusState::Normal;
67                    Some(NumberInputEvent::Left)
68                } else {
69                    None
70                }
71            }
72            _ => None,
73        }
74    }
75
76    /// Handle a keyboard event for this number input
77    ///
78    /// # Returns
79    /// * `Some(NumberInputEvent)` if the event was consumed
80    /// * `None` if the event was not relevant
81    pub fn handle_key(&mut self, key: KeyEvent) -> Option<NumberInputEvent> {
82        if !self.is_enabled() {
83            return None;
84        }
85
86        if self.editing() {
87            let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
88            let shift = key.modifiers.contains(KeyModifiers::SHIFT);
89
90            match key.code {
91                KeyCode::Enter => {
92                    let old_value = self.value;
93                    self.confirm_editing();
94                    if self.value != old_value {
95                        Some(NumberInputEvent::Changed(self.value))
96                    } else {
97                        Some(NumberInputEvent::CancelledEditing)
98                    }
99                }
100                KeyCode::Esc => {
101                    self.cancel_editing();
102                    Some(NumberInputEvent::CancelledEditing)
103                }
104                KeyCode::Backspace if ctrl => {
105                    self.delete_word_backward();
106                    None
107                }
108                KeyCode::Backspace => {
109                    self.backspace();
110                    None
111                }
112                KeyCode::Delete if ctrl => {
113                    self.delete_word_forward();
114                    None
115                }
116                KeyCode::Delete => {
117                    self.delete();
118                    None
119                }
120                KeyCode::Left if ctrl && shift => {
121                    self.move_word_left_selecting();
122                    None
123                }
124                KeyCode::Left if ctrl => {
125                    self.move_word_left();
126                    None
127                }
128                KeyCode::Left if shift => {
129                    self.move_left_selecting();
130                    None
131                }
132                KeyCode::Left => {
133                    self.move_left();
134                    None
135                }
136                KeyCode::Right if ctrl && shift => {
137                    self.move_word_right_selecting();
138                    None
139                }
140                KeyCode::Right if ctrl => {
141                    self.move_word_right();
142                    None
143                }
144                KeyCode::Right if shift => {
145                    self.move_right_selecting();
146                    None
147                }
148                KeyCode::Right => {
149                    self.move_right();
150                    None
151                }
152                KeyCode::Home if shift => {
153                    self.move_home_selecting();
154                    None
155                }
156                KeyCode::Home => {
157                    self.move_home();
158                    None
159                }
160                KeyCode::End if shift => {
161                    self.move_end_selecting();
162                    None
163                }
164                KeyCode::End => {
165                    self.move_end();
166                    None
167                }
168                KeyCode::Char('a') if ctrl => {
169                    self.select_all();
170                    None
171                }
172                KeyCode::Char(c) => {
173                    self.insert_char(c);
174                    None
175                }
176                _ => None,
177            }
178        } else if self.focus == FocusState::Focused {
179            match key.code {
180                KeyCode::Enter => {
181                    self.start_editing();
182                    Some(NumberInputEvent::StartedEditing)
183                }
184                // Direct-typing entry: pressing a digit, minus, or period on a
185                // focused number replaces the value with what the user typed.
186                // start_editing() select-alls so the first inserted char wipes
187                // the old value.
188                KeyCode::Char(c) if c.is_ascii_digit() || c == '-' || c == '.' => {
189                    self.start_editing();
190                    self.insert_char(c);
191                    Some(NumberInputEvent::StartedEditing)
192                }
193                _ => None,
194            }
195        } else {
196            None
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crossterm::event::KeyModifiers;
205    use ratatui::layout::Rect;
206
207    fn make_layout() -> NumberInputLayout {
208        NumberInputLayout {
209            value_area: Rect::new(8, 0, 7, 1),
210            decrement_area: Rect::default(),
211            increment_area: Rect::default(),
212            full_area: Rect::new(0, 0, 15, 1),
213        }
214    }
215
216    fn mouse_down(x: u16, y: u16) -> MouseEvent {
217        MouseEvent {
218            kind: MouseEventKind::Down(MouseButton::Left),
219            column: x,
220            row: y,
221            modifiers: KeyModifiers::empty(),
222        }
223    }
224
225    fn mouse_move(x: u16, y: u16) -> MouseEvent {
226        MouseEvent {
227            kind: MouseEventKind::Moved,
228            column: x,
229            row: y,
230            modifiers: KeyModifiers::empty(),
231        }
232    }
233
234    #[test]
235    fn test_click_value_starts_editing() {
236        let mut state = NumberInputState::new(42, "Value");
237        let layout = make_layout();
238
239        let result = state.handle_mouse(mouse_down(10, 0), &layout);
240        assert_eq!(result, Some(NumberInputEvent::StartedEditing));
241        assert!(state.editing());
242    }
243
244    #[test]
245    fn test_hover() {
246        let mut state = NumberInputState::new(42, "Value");
247        let layout = make_layout();
248
249        let result = state.handle_mouse(mouse_move(10, 0), &layout);
250        assert_eq!(result, Some(NumberInputEvent::Hovered));
251        assert_eq!(state.focus, FocusState::Hovered);
252
253        let result = state.handle_mouse(mouse_move(30, 0), &layout);
254        assert_eq!(result, Some(NumberInputEvent::Left));
255        assert_eq!(state.focus, FocusState::Normal);
256    }
257
258    #[test]
259    fn test_keyboard_digit_starts_editing_and_replaces_value() {
260        let mut state = NumberInputState::new(42, "Value").with_focus(FocusState::Focused);
261
262        let key = KeyEvent::new(KeyCode::Char('7'), KeyModifiers::empty());
263        let result = state.handle_key(key);
264        assert_eq!(result, Some(NumberInputEvent::StartedEditing));
265        assert!(state.editing());
266        // start_editing() select-alls so the typed digit replaces the value.
267        assert_eq!(state.display_text(), "7");
268    }
269
270    #[test]
271    fn test_editing_confirm() {
272        let mut state = NumberInputState::new(42, "Value");
273        state.start_editing();
274        // Select all and replace with new value
275        state.select_all();
276        state.insert_str("100");
277
278        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
279        let result = state.handle_key(enter);
280        assert_eq!(result, Some(NumberInputEvent::Changed(100)));
281        assert!(!state.editing());
282    }
283
284    #[test]
285    fn test_editing_cancel() {
286        let mut state = NumberInputState::new(42, "Value");
287        state.start_editing();
288        // Modify the value
289        state.select_all();
290        state.insert_str("100");
291
292        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
293        let result = state.handle_key(esc);
294        assert_eq!(result, Some(NumberInputEvent::CancelledEditing));
295        assert!(!state.editing());
296        assert_eq!(state.value, 42);
297    }
298
299    #[test]
300    fn test_editing_cursor_navigation() {
301        let mut state = NumberInputState::new(12345, "Value");
302        state.start_editing();
303        assert_eq!(state.cursor_col(), 5); // Cursor at end
304
305        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::empty());
306        state.handle_key(left);
307        assert_eq!(state.cursor_col(), 4);
308
309        let home = KeyEvent::new(KeyCode::Home, KeyModifiers::empty());
310        state.handle_key(home);
311        assert_eq!(state.cursor_col(), 0);
312
313        let end = KeyEvent::new(KeyCode::End, KeyModifiers::empty());
314        state.handle_key(end);
315        assert_eq!(state.cursor_col(), 5);
316    }
317
318    #[test]
319    fn test_editing_selection() {
320        let mut state = NumberInputState::new(123, "Value");
321        state.start_editing();
322
323        // Select all with Ctrl+A
324        let ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
325        state.handle_key(ctrl_a);
326        assert!(state.has_selection());
327
328        // Type to replace selection
329        let key_9 = KeyEvent::new(KeyCode::Char('9'), KeyModifiers::empty());
330        state.handle_key(key_9);
331        assert_eq!(state.display_text(), "9");
332    }
333
334    #[test]
335    fn test_disabled_ignores_input() {
336        let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
337        let layout = make_layout();
338
339        let result = state.handle_mouse(mouse_down(10, 0), &layout);
340        assert!(result.is_none());
341        assert_eq!(state.value, 5);
342    }
343}