fresh/view/
popup_input.rs

1//! Input handling for Popups.
2//!
3//! Implements the InputHandler trait for PopupManager, handling
4//! selection navigation and confirmation/cancellation.
5
6use super::popup::PopupManager;
7use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10impl InputHandler for PopupManager {
11    fn handle_key_event(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
12        // Only handle if there are active popups
13        if !self.is_visible() {
14            return InputResult::Ignored;
15        }
16
17        match event.code {
18            // Confirmation and cancellation
19            KeyCode::Enter => {
20                ctx.defer(DeferredAction::ConfirmPopup);
21                InputResult::Consumed
22            }
23            KeyCode::Esc => {
24                ctx.defer(DeferredAction::ClosePopup);
25                InputResult::Consumed
26            }
27
28            // Selection navigation
29            KeyCode::Up | KeyCode::Char('k') if event.modifiers.is_empty() => {
30                if let Some(popup) = self.top_mut() {
31                    popup.select_prev();
32                }
33                InputResult::Consumed
34            }
35            KeyCode::Down | KeyCode::Char('j') if event.modifiers.is_empty() => {
36                if let Some(popup) = self.top_mut() {
37                    popup.select_next();
38                }
39                InputResult::Consumed
40            }
41            KeyCode::PageUp => {
42                if let Some(popup) = self.top_mut() {
43                    popup.page_up();
44                }
45                InputResult::Consumed
46            }
47            KeyCode::PageDown => {
48                if let Some(popup) = self.top_mut() {
49                    popup.page_down();
50                }
51                InputResult::Consumed
52            }
53            KeyCode::Home => {
54                if let Some(popup) = self.top_mut() {
55                    popup.select_first();
56                }
57                InputResult::Consumed
58            }
59            KeyCode::End => {
60                if let Some(popup) = self.top_mut() {
61                    popup.select_last();
62                }
63                InputResult::Consumed
64            }
65
66            // Tab also navigates
67            KeyCode::Tab if event.modifiers.is_empty() => {
68                if let Some(popup) = self.top_mut() {
69                    popup.select_next();
70                }
71                InputResult::Consumed
72            }
73            KeyCode::BackTab => {
74                if let Some(popup) = self.top_mut() {
75                    popup.select_prev();
76                }
77                InputResult::Consumed
78            }
79
80            // Type-to-filter for completion popups
81            KeyCode::Char(c) if event.modifiers.is_empty() => {
82                // Check if this is a completion popup that supports type-to-filter
83                if self.is_completion_popup() {
84                    ctx.defer(DeferredAction::PopupTypeChar(c));
85                }
86                InputResult::Consumed
87            }
88
89            // Backspace for type-to-filter in completion popups
90            KeyCode::Backspace if event.modifiers.is_empty() => {
91                if self.is_completion_popup() {
92                    ctx.defer(DeferredAction::PopupBackspace);
93                }
94                InputResult::Consumed
95            }
96
97            // Ctrl+C to copy selected text from popup
98            KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => {
99                if let Some(popup) = self.top() {
100                    if popup.has_selection() {
101                        if let Some(text) = popup.get_selected_text() {
102                            ctx.defer(DeferredAction::CopyToClipboard(text));
103                        }
104                    }
105                }
106                InputResult::Consumed
107            }
108
109            // Consume all other keys (modal behavior)
110            _ => InputResult::Consumed,
111        }
112    }
113
114    fn is_modal(&self) -> bool {
115        self.is_visible()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::view::popup::{Popup, PopupListItem};
123    use crate::view::theme;
124    use crate::view::theme::Theme;
125    use crossterm::event::KeyModifiers;
126
127    fn key(code: KeyCode) -> KeyEvent {
128        KeyEvent::new(code, KeyModifiers::NONE)
129    }
130
131    fn create_popup_with_items(count: usize) -> PopupManager {
132        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
133        let items: Vec<PopupListItem> = (0..count)
134            .map(|i| PopupListItem::new(format!("Item {}", i)))
135            .collect();
136        let popup = Popup::list(items, &theme);
137        let mut manager = PopupManager::new();
138        manager.show(popup);
139        manager
140    }
141
142    #[test]
143    fn test_popup_navigation() {
144        let mut manager = create_popup_with_items(5);
145        let mut ctx = InputContext::new();
146
147        // Initially at item 0
148        assert_eq!(
149            manager.top().unwrap().selected_item().unwrap().text,
150            "Item 0"
151        );
152
153        // Down arrow moves to next
154        manager.handle_key_event(&key(KeyCode::Down), &mut ctx);
155        assert_eq!(
156            manager.top().unwrap().selected_item().unwrap().text,
157            "Item 1"
158        );
159
160        // Up arrow moves back
161        manager.handle_key_event(&key(KeyCode::Up), &mut ctx);
162        assert_eq!(
163            manager.top().unwrap().selected_item().unwrap().text,
164            "Item 0"
165        );
166
167        // j/k also work
168        manager.handle_key_event(
169            &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
170            &mut ctx,
171        );
172        assert_eq!(
173            manager.top().unwrap().selected_item().unwrap().text,
174            "Item 1"
175        );
176
177        manager.handle_key_event(
178            &KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
179            &mut ctx,
180        );
181        assert_eq!(
182            manager.top().unwrap().selected_item().unwrap().text,
183            "Item 0"
184        );
185    }
186
187    #[test]
188    fn test_popup_enter_confirms() {
189        let mut manager = create_popup_with_items(3);
190        let mut ctx = InputContext::new();
191
192        manager.handle_key_event(&key(KeyCode::Enter), &mut ctx);
193        assert!(ctx
194            .deferred_actions
195            .iter()
196            .any(|a| matches!(a, DeferredAction::ConfirmPopup)));
197    }
198
199    #[test]
200    fn test_popup_escape_cancels() {
201        let mut manager = create_popup_with_items(3);
202        let mut ctx = InputContext::new();
203
204        manager.handle_key_event(&key(KeyCode::Esc), &mut ctx);
205        assert!(ctx
206            .deferred_actions
207            .iter()
208            .any(|a| matches!(a, DeferredAction::ClosePopup)));
209    }
210
211    #[test]
212    fn test_popup_is_modal_when_visible() {
213        let mut manager = PopupManager::new();
214        assert!(!manager.is_modal());
215
216        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
217        manager.show(Popup::text(vec!["test".to_string()], &theme));
218        assert!(manager.is_modal());
219    }
220
221    #[test]
222    fn test_popup_ignored_when_empty() {
223        let mut manager = PopupManager::new();
224        let mut ctx = InputContext::new();
225
226        let result = manager.handle_key_event(&key(KeyCode::Down), &mut ctx);
227        assert_eq!(result, InputResult::Ignored);
228    }
229}