Skip to main content

fresh/view/controls/text_list/
input.rs

1//! Text list input handling
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4
5use super::{TextListHit, TextListLayout, TextListState};
6
7/// Events that can be returned from text list input handling
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum TextListEvent {
10    /// An item was added
11    ItemAdded(String),
12    /// An item was removed
13    ItemRemoved(usize),
14    /// An item was changed
15    ItemChanged(usize, String),
16    /// Focus moved to a different item
17    FocusChanged(Option<usize>),
18}
19
20impl TextListState {
21    /// Handle a mouse event for this text list
22    ///
23    /// # Arguments
24    /// * `event` - The mouse event to handle
25    /// * `layout` - The control's rendered layout for hit testing
26    ///
27    /// # Returns
28    /// * `Some(TextListEvent)` if the event was consumed
29    /// * `None` if the event was not relevant
30    pub fn handle_mouse(
31        &mut self,
32        event: MouseEvent,
33        layout: &TextListLayout,
34    ) -> Option<TextListEvent> {
35        if !self.is_enabled() {
36            return None;
37        }
38
39        if let MouseEventKind::Down(MouseButton::Left) = event.kind {
40            if let Some(hit) = layout.hit_test(event.column, event.row) {
41                match hit {
42                    TextListHit::Button(Some(index)) => {
43                        // Remove button clicked
44                        self.remove_item(index);
45                        return Some(TextListEvent::ItemRemoved(index));
46                    }
47                    TextListHit::Button(None) => {
48                        // Add button clicked
49                        if !self.new_item_text.is_empty() {
50                            let item = self.new_item_text.clone();
51                            self.add_item();
52                            return Some(TextListEvent::ItemAdded(item));
53                        }
54                    }
55                    TextListHit::TextField(Some(index)) => {
56                        // Focus on existing item
57                        self.focus_item(index);
58                        return Some(TextListEvent::FocusChanged(Some(index)));
59                    }
60                    TextListHit::TextField(None) => {
61                        // Focus on add-new field
62                        self.focus_new_item();
63                        return Some(TextListEvent::FocusChanged(None));
64                    }
65                }
66            }
67        }
68        None
69    }
70
71    /// Handle a keyboard event for this text list
72    ///
73    /// # Returns
74    /// * `Some(TextListEvent)` if the event was consumed
75    /// * `None` if the event was not relevant
76    pub fn handle_key(&mut self, key: KeyEvent) -> Option<TextListEvent> {
77        if !self.is_enabled() {
78            return None;
79        }
80
81        match key.code {
82            KeyCode::Enter => {
83                if self.focused_item.is_none() && !self.new_item_text.is_empty() {
84                    let item = self.new_item_text.clone();
85                    self.add_item();
86                    Some(TextListEvent::ItemAdded(item))
87                } else {
88                    None
89                }
90            }
91            KeyCode::Backspace => {
92                if self.cursor > 0 {
93                    self.backspace();
94                    if let Some(idx) = self.focused_item {
95                        Some(TextListEvent::ItemChanged(idx, self.items[idx].clone()))
96                    } else {
97                        None
98                    }
99                } else {
100                    None
101                }
102            }
103            KeyCode::Delete => {
104                if let Some(idx) = self.focused_item {
105                    if idx < self.items.len() {
106                        self.remove_item(idx);
107                        return Some(TextListEvent::ItemRemoved(idx));
108                    }
109                }
110                None
111            }
112            KeyCode::Left => {
113                self.move_left();
114                None
115            }
116            KeyCode::Right => {
117                self.move_right();
118                None
119            }
120            KeyCode::Up => {
121                self.focus_prev();
122                Some(TextListEvent::FocusChanged(self.focused_item))
123            }
124            KeyCode::Down => {
125                self.focus_next();
126                Some(TextListEvent::FocusChanged(self.focused_item))
127            }
128            KeyCode::Char(c) => {
129                self.insert(c);
130                if let Some(idx) = self.focused_item {
131                    Some(TextListEvent::ItemChanged(idx, self.items[idx].clone()))
132                } else {
133                    None
134                }
135            }
136            _ => None,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crossterm::event::KeyModifiers;
145    use ratatui::layout::Rect;
146
147    fn make_layout() -> TextListLayout {
148        TextListLayout {
149            rows: vec![
150                super::super::TextListRowLayout {
151                    text_area: Rect::new(2, 1, 22, 1),
152                    button_area: Rect::new(25, 1, 3, 1),
153                    index: Some(0),
154                },
155                super::super::TextListRowLayout {
156                    text_area: Rect::new(2, 2, 22, 1),
157                    button_area: Rect::new(25, 2, 3, 1),
158                    index: None,
159                },
160            ],
161            full_area: Rect::new(0, 0, 30, 3),
162        }
163    }
164
165    fn mouse_down(x: u16, y: u16) -> MouseEvent {
166        MouseEvent {
167            kind: MouseEventKind::Down(MouseButton::Left),
168            column: x,
169            row: y,
170            modifiers: KeyModifiers::empty(),
171        }
172    }
173
174    #[test]
175    fn test_click_remove_button() {
176        let mut state = TextListState::new("Items").with_items(vec!["item".to_string()]);
177        let layout = make_layout();
178
179        let result = state.handle_mouse(mouse_down(25, 1), &layout);
180        assert_eq!(result, Some(TextListEvent::ItemRemoved(0)));
181        assert!(state.items.is_empty());
182    }
183
184    #[test]
185    fn test_click_add_button() {
186        let mut state = TextListState::new("Items");
187        state.new_item_text = "new".to_string();
188        let layout = make_layout();
189
190        let result = state.handle_mouse(mouse_down(25, 2), &layout);
191        assert_eq!(result, Some(TextListEvent::ItemAdded("new".to_string())));
192        assert_eq!(state.items, vec!["new"]);
193    }
194
195    #[test]
196    fn test_click_text_field() {
197        let mut state = TextListState::new("Items").with_items(vec!["item".to_string()]);
198        let layout = make_layout();
199
200        let result = state.handle_mouse(mouse_down(10, 1), &layout);
201        assert_eq!(result, Some(TextListEvent::FocusChanged(Some(0))));
202        assert_eq!(state.focused_item, Some(0));
203    }
204
205    #[test]
206    fn test_keyboard_navigation() {
207        let mut state =
208            TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
209        state.focus_new_item();
210
211        let up = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
212        let result = state.handle_key(up);
213        assert_eq!(result, Some(TextListEvent::FocusChanged(Some(1))));
214
215        let result = state.handle_key(up);
216        assert_eq!(result, Some(TextListEvent::FocusChanged(Some(0))));
217    }
218
219    #[test]
220    fn test_enter_adds_item() {
221        let mut state = TextListState::new("Items");
222        state.new_item_text = "test".to_string();
223
224        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
225        let result = state.handle_key(enter);
226        assert_eq!(result, Some(TextListEvent::ItemAdded("test".to_string())));
227        assert_eq!(state.items, vec!["test"]);
228    }
229
230    #[test]
231    fn test_delete_removes_focused_item() {
232        let mut state =
233            TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
234        state.focus_item(0);
235
236        let delete = KeyEvent::new(KeyCode::Delete, KeyModifiers::empty());
237        let result = state.handle_key(delete);
238        assert_eq!(result, Some(TextListEvent::ItemRemoved(0)));
239        assert_eq!(state.items, vec!["b"]);
240    }
241
242    #[test]
243    fn test_typing_in_item() {
244        let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
245        state.focus_item(0);
246
247        let key = KeyEvent::new(KeyCode::Char('!'), KeyModifiers::empty());
248        let result = state.handle_key(key);
249        assert_eq!(
250            result,
251            Some(TextListEvent::ItemChanged(0, "hello!".to_string()))
252        );
253    }
254}