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_increment(event.column, event.row) {
48                    self.increment();
49                    Some(NumberInputEvent::Incremented(self.value))
50                } else if layout.is_decrement(event.column, event.row) {
51                    self.decrement();
52                    Some(NumberInputEvent::Decremented(self.value))
53                } else if layout.is_value(event.column, event.row) {
54                    if !self.editing() {
55                        self.start_editing();
56                        Some(NumberInputEvent::StartedEditing)
57                    } else {
58                        None
59                    }
60                } else {
61                    None
62                }
63            }
64            MouseEventKind::Moved => {
65                let inside = layout.contains(event.column, event.row);
66                if inside {
67                    if self.focus != FocusState::Focused {
68                        self.focus = FocusState::Hovered;
69                    }
70                    Some(NumberInputEvent::Hovered)
71                } else if self.focus == FocusState::Hovered {
72                    self.focus = FocusState::Normal;
73                    Some(NumberInputEvent::Left)
74                } else {
75                    None
76                }
77            }
78            _ => None,
79        }
80    }
81
82    /// Handle a keyboard event for this number input
83    ///
84    /// # Returns
85    /// * `Some(NumberInputEvent)` if the event was consumed
86    /// * `None` if the event was not relevant
87    pub fn handle_key(&mut self, key: KeyEvent) -> Option<NumberInputEvent> {
88        if !self.is_enabled() {
89            return None;
90        }
91
92        if self.editing() {
93            let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
94            let shift = key.modifiers.contains(KeyModifiers::SHIFT);
95
96            match key.code {
97                KeyCode::Enter => {
98                    let old_value = self.value;
99                    self.confirm_editing();
100                    if self.value != old_value {
101                        Some(NumberInputEvent::Changed(self.value))
102                    } else {
103                        Some(NumberInputEvent::CancelledEditing)
104                    }
105                }
106                KeyCode::Esc => {
107                    self.cancel_editing();
108                    Some(NumberInputEvent::CancelledEditing)
109                }
110                KeyCode::Backspace if ctrl => {
111                    self.delete_word_backward();
112                    None
113                }
114                KeyCode::Backspace => {
115                    self.backspace();
116                    None
117                }
118                KeyCode::Delete if ctrl => {
119                    self.delete_word_forward();
120                    None
121                }
122                KeyCode::Delete => {
123                    self.delete();
124                    None
125                }
126                KeyCode::Left if ctrl && shift => {
127                    self.move_word_left_selecting();
128                    None
129                }
130                KeyCode::Left if ctrl => {
131                    self.move_word_left();
132                    None
133                }
134                KeyCode::Left if shift => {
135                    self.move_left_selecting();
136                    None
137                }
138                KeyCode::Left => {
139                    self.move_left();
140                    None
141                }
142                KeyCode::Right if ctrl && shift => {
143                    self.move_word_right_selecting();
144                    None
145                }
146                KeyCode::Right if ctrl => {
147                    self.move_word_right();
148                    None
149                }
150                KeyCode::Right if shift => {
151                    self.move_right_selecting();
152                    None
153                }
154                KeyCode::Right => {
155                    self.move_right();
156                    None
157                }
158                KeyCode::Home if shift => {
159                    self.move_home_selecting();
160                    None
161                }
162                KeyCode::Home => {
163                    self.move_home();
164                    None
165                }
166                KeyCode::End if shift => {
167                    self.move_end_selecting();
168                    None
169                }
170                KeyCode::End => {
171                    self.move_end();
172                    None
173                }
174                KeyCode::Char('a') if ctrl => {
175                    self.select_all();
176                    None
177                }
178                KeyCode::Char(c) => {
179                    self.insert_char(c);
180                    None
181                }
182                _ => None,
183            }
184        } else if self.focus == FocusState::Focused {
185            match key.code {
186                KeyCode::Up | KeyCode::Char('+') | KeyCode::Char('=') => {
187                    self.increment();
188                    Some(NumberInputEvent::Incremented(self.value))
189                }
190                KeyCode::Down | KeyCode::Char('-') => {
191                    self.decrement();
192                    Some(NumberInputEvent::Decremented(self.value))
193                }
194                KeyCode::Enter => {
195                    self.start_editing();
196                    Some(NumberInputEvent::StartedEditing)
197                }
198                _ => None,
199            }
200        } else {
201            None
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crossterm::event::KeyModifiers;
210    use ratatui::layout::Rect;
211
212    fn make_layout() -> NumberInputLayout {
213        NumberInputLayout {
214            value_area: Rect::new(8, 0, 7, 1),
215            decrement_area: Rect::new(16, 0, 3, 1),
216            increment_area: Rect::new(20, 0, 3, 1),
217            full_area: Rect::new(0, 0, 23, 1),
218        }
219    }
220
221    fn mouse_down(x: u16, y: u16) -> MouseEvent {
222        MouseEvent {
223            kind: MouseEventKind::Down(MouseButton::Left),
224            column: x,
225            row: y,
226            modifiers: KeyModifiers::empty(),
227        }
228    }
229
230    fn mouse_move(x: u16, y: u16) -> MouseEvent {
231        MouseEvent {
232            kind: MouseEventKind::Moved,
233            column: x,
234            row: y,
235            modifiers: KeyModifiers::empty(),
236        }
237    }
238
239    #[test]
240    fn test_click_increment() {
241        let mut state = NumberInputState::new(5, "Value");
242        let layout = make_layout();
243
244        let result = state.handle_mouse(mouse_down(20, 0), &layout);
245        assert_eq!(result, Some(NumberInputEvent::Incremented(6)));
246        assert_eq!(state.value, 6);
247    }
248
249    #[test]
250    fn test_click_decrement() {
251        let mut state = NumberInputState::new(5, "Value");
252        let layout = make_layout();
253
254        let result = state.handle_mouse(mouse_down(16, 0), &layout);
255        assert_eq!(result, Some(NumberInputEvent::Decremented(4)));
256        assert_eq!(state.value, 4);
257    }
258
259    #[test]
260    fn test_click_value_starts_editing() {
261        let mut state = NumberInputState::new(42, "Value");
262        let layout = make_layout();
263
264        let result = state.handle_mouse(mouse_down(10, 0), &layout);
265        assert_eq!(result, Some(NumberInputEvent::StartedEditing));
266        assert!(state.editing());
267    }
268
269    #[test]
270    fn test_hover() {
271        let mut state = NumberInputState::new(42, "Value");
272        let layout = make_layout();
273
274        let result = state.handle_mouse(mouse_move(10, 0), &layout);
275        assert_eq!(result, Some(NumberInputEvent::Hovered));
276        assert_eq!(state.focus, FocusState::Hovered);
277
278        let result = state.handle_mouse(mouse_move(30, 0), &layout);
279        assert_eq!(result, Some(NumberInputEvent::Left));
280        assert_eq!(state.focus, FocusState::Normal);
281    }
282
283    #[test]
284    fn test_keyboard_increment() {
285        let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Focused);
286
287        let up = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
288        let result = state.handle_key(up);
289        assert_eq!(result, Some(NumberInputEvent::Incremented(6)));
290    }
291
292    #[test]
293    fn test_keyboard_decrement() {
294        let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Focused);
295
296        let down = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
297        let result = state.handle_key(down);
298        assert_eq!(result, Some(NumberInputEvent::Decremented(4)));
299    }
300
301    #[test]
302    fn test_editing_confirm() {
303        let mut state = NumberInputState::new(42, "Value");
304        state.start_editing();
305        // Select all and replace with new value
306        state.select_all();
307        state.insert_str("100");
308
309        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
310        let result = state.handle_key(enter);
311        assert_eq!(result, Some(NumberInputEvent::Changed(100)));
312        assert!(!state.editing());
313    }
314
315    #[test]
316    fn test_editing_cancel() {
317        let mut state = NumberInputState::new(42, "Value");
318        state.start_editing();
319        // Modify the value
320        state.select_all();
321        state.insert_str("100");
322
323        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
324        let result = state.handle_key(esc);
325        assert_eq!(result, Some(NumberInputEvent::CancelledEditing));
326        assert!(!state.editing());
327        assert_eq!(state.value, 42);
328    }
329
330    #[test]
331    fn test_editing_cursor_navigation() {
332        let mut state = NumberInputState::new(12345, "Value");
333        state.start_editing();
334        assert_eq!(state.cursor_col(), 5); // Cursor at end
335
336        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::empty());
337        state.handle_key(left);
338        assert_eq!(state.cursor_col(), 4);
339
340        let home = KeyEvent::new(KeyCode::Home, KeyModifiers::empty());
341        state.handle_key(home);
342        assert_eq!(state.cursor_col(), 0);
343
344        let end = KeyEvent::new(KeyCode::End, KeyModifiers::empty());
345        state.handle_key(end);
346        assert_eq!(state.cursor_col(), 5);
347    }
348
349    #[test]
350    fn test_editing_selection() {
351        let mut state = NumberInputState::new(123, "Value");
352        state.start_editing();
353
354        // Select all with Ctrl+A
355        let ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
356        state.handle_key(ctrl_a);
357        assert!(state.has_selection());
358
359        // Type to replace selection
360        let key_9 = KeyEvent::new(KeyCode::Char('9'), KeyModifiers::empty());
361        state.handle_key(key_9);
362        assert_eq!(state.display_text(), "9");
363    }
364
365    #[test]
366    fn test_disabled_ignores_input() {
367        let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
368        let layout = make_layout();
369
370        let result = state.handle_mouse(mouse_down(20, 0), &layout);
371        assert!(result.is_none());
372        assert_eq!(state.value, 5);
373    }
374}