Skip to main content

fresh/view/controls/dropdown/
input.rs

1//! Dropdown input handling
2
3use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4
5use super::{DropdownLayout, DropdownState, FocusState};
6
7/// Events that can be returned from dropdown input handling
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum DropdownEvent {
10    /// Dropdown was opened
11    Opened,
12    /// Dropdown was closed (confirmed selection)
13    Closed,
14    /// Selection was changed
15    SelectionChanged(usize),
16    /// Selection was cancelled (restored original)
17    Cancelled,
18    /// Mouse is hovering
19    Hovered,
20    /// Mouse left the area
21    Left,
22}
23
24impl DropdownState {
25    /// Handle a mouse event for this dropdown
26    ///
27    /// # Arguments
28    /// * `event` - The mouse event to handle
29    /// * `layout` - The control's rendered layout for hit testing
30    ///
31    /// # Returns
32    /// * `Some(DropdownEvent)` if the event was consumed
33    /// * `None` if the event was not relevant
34    pub fn handle_mouse(
35        &mut self,
36        event: MouseEvent,
37        layout: &DropdownLayout,
38    ) -> Option<DropdownEvent> {
39        if !self.is_enabled() {
40            return None;
41        }
42
43        match event.kind {
44            MouseEventKind::Down(MouseButton::Left) => {
45                if self.open {
46                    // Check if clicked on an option
47                    if let Some(index) = layout.option_at(event.column, event.row) {
48                        self.select(index);
49                        return Some(DropdownEvent::SelectionChanged(index));
50                    }
51                    // Check if clicked on button (to close)
52                    if layout.is_button(event.column, event.row) {
53                        self.toggle_open();
54                        return Some(DropdownEvent::Closed);
55                    }
56                    // Clicked outside - close and cancel
57                    self.cancel();
58                    return Some(DropdownEvent::Cancelled);
59                } else {
60                    // Closed - check if clicked on button to open
61                    if layout.is_button(event.column, event.row) {
62                        self.toggle_open();
63                        return Some(DropdownEvent::Opened);
64                    }
65                }
66                None
67            }
68            MouseEventKind::Moved => {
69                let inside = layout.is_button(event.column, event.row)
70                    || layout.option_at(event.column, event.row).is_some();
71
72                if inside {
73                    if self.focus != FocusState::Focused && self.focus != FocusState::Hovered {
74                        self.focus = FocusState::Hovered;
75                    }
76                    Some(DropdownEvent::Hovered)
77                } else if self.focus == FocusState::Hovered && !self.open {
78                    self.focus = FocusState::Normal;
79                    Some(DropdownEvent::Left)
80                } else {
81                    None
82                }
83            }
84            MouseEventKind::ScrollUp => {
85                if self.open {
86                    self.scroll_by(-3);
87                    Some(DropdownEvent::SelectionChanged(self.selected))
88                } else {
89                    None
90                }
91            }
92            MouseEventKind::ScrollDown => {
93                if self.open {
94                    self.scroll_by(3);
95                    Some(DropdownEvent::SelectionChanged(self.selected))
96                } else {
97                    None
98                }
99            }
100            _ => None,
101        }
102    }
103
104    /// Handle a keyboard event for this dropdown
105    ///
106    /// # Returns
107    /// * `Some(DropdownEvent)` if the event was consumed
108    /// * `None` if the event was not relevant
109    pub fn handle_key(&mut self, key: KeyEvent) -> Option<DropdownEvent> {
110        if !self.is_enabled() {
111            return None;
112        }
113
114        // Only handle keys when focused
115        if self.focus != FocusState::Focused && !self.open {
116            return None;
117        }
118
119        match key.code {
120            KeyCode::Enter | KeyCode::Char(' ') => {
121                if self.open {
122                    self.confirm();
123                    Some(DropdownEvent::Closed)
124                } else {
125                    self.toggle_open();
126                    Some(DropdownEvent::Opened)
127                }
128            }
129            KeyCode::Esc => {
130                if self.open {
131                    self.cancel();
132                    Some(DropdownEvent::Cancelled)
133                } else {
134                    None
135                }
136            }
137            KeyCode::Up | KeyCode::Char('k') => {
138                self.select_prev();
139                Some(DropdownEvent::SelectionChanged(self.selected))
140            }
141            KeyCode::Down | KeyCode::Char('j') => {
142                self.select_next();
143                Some(DropdownEvent::SelectionChanged(self.selected))
144            }
145            KeyCode::Home => {
146                if !self.options.is_empty() {
147                    self.selected = 0;
148                    self.ensure_visible();
149                    Some(DropdownEvent::SelectionChanged(0))
150                } else {
151                    None
152                }
153            }
154            KeyCode::End => {
155                if !self.options.is_empty() {
156                    self.selected = self.options.len() - 1;
157                    self.ensure_visible();
158                    Some(DropdownEvent::SelectionChanged(self.selected))
159                } else {
160                    None
161                }
162            }
163            _ => None,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crossterm::event::KeyModifiers;
172    use ratatui::layout::Rect;
173
174    fn make_layout(open: bool) -> DropdownLayout {
175        let mut layout = DropdownLayout {
176            button_area: Rect::new(10, 0, 15, 1),
177            option_areas: Vec::new(),
178            full_area: Rect::new(0, 0, 25, 1),
179            scroll_offset: 0,
180        };
181        if open {
182            layout.option_areas = vec![
183                Rect::new(10, 1, 15, 1),
184                Rect::new(10, 2, 15, 1),
185                Rect::new(10, 3, 15, 1),
186            ];
187        }
188        layout
189    }
190
191    fn mouse_down(x: u16, y: u16) -> MouseEvent {
192        MouseEvent {
193            kind: MouseEventKind::Down(MouseButton::Left),
194            column: x,
195            row: y,
196            modifiers: KeyModifiers::empty(),
197        }
198    }
199
200    #[test]
201    fn test_click_opens() {
202        let mut state = DropdownState::new(
203            vec!["A".to_string(), "B".to_string(), "C".to_string()],
204            "Test",
205        );
206        let layout = make_layout(false);
207
208        let result = state.handle_mouse(mouse_down(12, 0), &layout);
209        assert_eq!(result, Some(DropdownEvent::Opened));
210        assert!(state.open);
211    }
212
213    #[test]
214    fn test_click_option_selects() {
215        let mut state = DropdownState::new(
216            vec!["A".to_string(), "B".to_string(), "C".to_string()],
217            "Test",
218        );
219        state.open = true;
220        let layout = make_layout(true);
221
222        let result = state.handle_mouse(mouse_down(12, 2), &layout);
223        assert_eq!(result, Some(DropdownEvent::SelectionChanged(1)));
224        assert_eq!(state.selected, 1);
225        assert!(!state.open);
226    }
227
228    #[test]
229    fn test_click_outside_cancels() {
230        let mut state = DropdownState::new(
231            vec!["A".to_string(), "B".to_string(), "C".to_string()],
232            "Test",
233        )
234        .with_selected(1);
235        state.toggle_open();
236        state.select_next();
237        assert_eq!(state.selected, 2);
238
239        let layout = make_layout(true);
240
241        let result = state.handle_mouse(mouse_down(0, 5), &layout);
242        assert_eq!(result, Some(DropdownEvent::Cancelled));
243        assert!(!state.open);
244        assert_eq!(state.selected, 1); // Restored
245    }
246
247    #[test]
248    fn test_keyboard_navigation() {
249        let mut state = DropdownState::new(
250            vec!["A".to_string(), "B".to_string(), "C".to_string()],
251            "Test",
252        )
253        .with_focus(FocusState::Focused);
254
255        let down = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
256        let result = state.handle_key(down);
257        assert_eq!(result, Some(DropdownEvent::SelectionChanged(1)));
258
259        let up = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
260        let result = state.handle_key(up);
261        assert_eq!(result, Some(DropdownEvent::SelectionChanged(0)));
262    }
263
264    #[test]
265    fn test_enter_toggles() {
266        let mut state = DropdownState::new(vec!["A".to_string(), "B".to_string()], "Test")
267            .with_focus(FocusState::Focused);
268
269        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
270        let result = state.handle_key(enter);
271        assert_eq!(result, Some(DropdownEvent::Opened));
272        assert!(state.open);
273
274        let result = state.handle_key(enter);
275        assert_eq!(result, Some(DropdownEvent::Closed));
276        assert!(!state.open);
277    }
278
279    #[test]
280    fn test_escape_cancels() {
281        let mut state = DropdownState::new(
282            vec!["A".to_string(), "B".to_string(), "C".to_string()],
283            "Test",
284        )
285        .with_focus(FocusState::Focused);
286
287        state.toggle_open();
288        state.select_next();
289        assert_eq!(state.selected, 1);
290
291        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
292        let result = state.handle_key(esc);
293        assert_eq!(result, Some(DropdownEvent::Cancelled));
294        assert!(!state.open);
295        assert_eq!(state.selected, 0); // Restored
296    }
297}