Skip to main content

fresh/view/ui/
menu_input.rs

1//! Input handling for the Menu system.
2//!
3//! Provides InputHandler implementation for menu navigation.
4//! Uses a wrapper struct to bundle MenuState with menu configuration.
5
6use super::menu::MenuState;
7use crate::config::Menu;
8use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
9use crossterm::event::{KeyCode, KeyEvent};
10
11/// Wrapper that provides InputHandler for MenuState with menu configuration.
12pub struct MenuInputHandler<'a> {
13    pub state: &'a mut MenuState,
14    pub menus: &'a [Menu],
15}
16
17impl<'a> MenuInputHandler<'a> {
18    pub fn new(state: &'a mut MenuState, menus: &'a [Menu]) -> Self {
19        Self { state, menus }
20    }
21}
22
23impl InputHandler for MenuInputHandler<'_> {
24    fn handle_key_event(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
25        // Only handle if menu is active
26        if self.state.active_menu.is_none() {
27            return InputResult::Ignored;
28        }
29
30        match event.code {
31            // Close menu
32            KeyCode::Esc => {
33                ctx.defer(DeferredAction::CloseMenu);
34                InputResult::Consumed
35            }
36
37            // Execute/confirm
38            KeyCode::Enter => {
39                // Check if highlighted item is a submenu - if so, open it
40                if self.state.is_highlighted_submenu(self.menus) {
41                    self.state.open_submenu(self.menus);
42                    return InputResult::Consumed;
43                }
44
45                // Get the action to execute
46                if let Some((action, args)) = self.state.get_highlighted_action(self.menus) {
47                    ctx.defer(DeferredAction::ExecuteMenuAction { action, args });
48                    ctx.defer(DeferredAction::CloseMenu);
49                }
50                InputResult::Consumed
51            }
52
53            // Navigation
54            KeyCode::Up | KeyCode::Char('k') if event.modifiers.is_empty() => {
55                if let Some(active_idx) = self.state.active_menu {
56                    if let Some(menu) = self.menus.get(active_idx) {
57                        self.state.prev_item(menu);
58                    }
59                }
60                InputResult::Consumed
61            }
62            KeyCode::Down | KeyCode::Char('j') if event.modifiers.is_empty() => {
63                if let Some(active_idx) = self.state.active_menu {
64                    if let Some(menu) = self.menus.get(active_idx) {
65                        self.state.next_item(menu);
66                    }
67                }
68                InputResult::Consumed
69            }
70            KeyCode::Left | KeyCode::Char('h') if event.modifiers.is_empty() => {
71                // If in a submenu, close it and go back to parent
72                // Otherwise, go to the previous menu
73                if !self.state.close_submenu() {
74                    self.state.prev_menu(self.menus);
75                }
76                InputResult::Consumed
77            }
78            KeyCode::Right | KeyCode::Char('l') if event.modifiers.is_empty() => {
79                // If on a submenu item, open it
80                // Otherwise, go to the next menu
81                if !self.state.open_submenu(self.menus) {
82                    self.state.next_menu(self.menus);
83                }
84                InputResult::Consumed
85            }
86
87            // Home/End for quick navigation
88            KeyCode::Home => {
89                self.state.highlighted_item = Some(0);
90                InputResult::Consumed
91            }
92            KeyCode::End => {
93                if let Some(active_idx) = self.state.active_menu {
94                    if let Some(menu) = self.menus.get(active_idx) {
95                        if let Some(items) = self.state.get_current_items_cloned(menu) {
96                            if !items.is_empty() {
97                                self.state.highlighted_item = Some(items.len() - 1);
98                            }
99                        }
100                    }
101                }
102                InputResult::Consumed
103            }
104
105            // Consume all other keys (modal behavior)
106            _ => InputResult::Consumed,
107        }
108    }
109
110    fn is_modal(&self) -> bool {
111        self.state.active_menu.is_some()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::config::MenuItem;
119    use crossterm::event::KeyModifiers;
120    use std::collections::HashMap;
121
122    fn key(code: KeyCode) -> KeyEvent {
123        KeyEvent::new(code, KeyModifiers::NONE)
124    }
125
126    fn create_test_menus() -> Vec<Menu> {
127        vec![
128            Menu {
129                id: None,
130                label: "File".to_string(),
131                items: vec![
132                    MenuItem::Action {
133                        label: "New".to_string(),
134                        action: "new_file".to_string(),
135                        args: HashMap::new(),
136                        when: None,
137                        checkbox: None,
138                    },
139                    MenuItem::Separator { separator: true },
140                    MenuItem::Action {
141                        label: "Save".to_string(),
142                        action: "save".to_string(),
143                        args: HashMap::new(),
144                        when: None,
145                        checkbox: None,
146                    },
147                ],
148                when: None,
149            },
150            Menu {
151                id: None,
152                label: "Edit".to_string(),
153                items: vec![
154                    MenuItem::Action {
155                        label: "Undo".to_string(),
156                        action: "undo".to_string(),
157                        args: HashMap::new(),
158                        when: None,
159                        checkbox: None,
160                    },
161                    MenuItem::Action {
162                        label: "Redo".to_string(),
163                        action: "redo".to_string(),
164                        args: HashMap::new(),
165                        when: None,
166                        checkbox: None,
167                    },
168                ],
169                when: None,
170            },
171        ]
172    }
173
174    #[test]
175    fn test_menu_navigation_down() {
176        let menus = create_test_menus();
177        let mut state = MenuState::for_testing();
178        state.open_menu(0);
179
180        let mut handler = MenuInputHandler::new(&mut state, &menus);
181        let mut ctx = InputContext::new();
182
183        // Initially at first item
184        assert_eq!(handler.state.highlighted_item, Some(0));
185
186        // Down arrow moves to next (skipping separator)
187        handler.handle_key_event(&key(KeyCode::Down), &mut ctx);
188        assert_eq!(handler.state.highlighted_item, Some(2)); // Skipped separator at 1
189    }
190
191    #[test]
192    fn test_menu_navigation_between_menus() {
193        let menus = create_test_menus();
194        let mut state = MenuState::for_testing();
195        state.open_menu(0);
196
197        let mut handler = MenuInputHandler::new(&mut state, &menus);
198        let mut ctx = InputContext::new();
199
200        // Initially on File menu
201        assert_eq!(handler.state.active_menu, Some(0));
202
203        // Right arrow moves to next menu
204        handler.handle_key_event(&key(KeyCode::Right), &mut ctx);
205        assert_eq!(handler.state.active_menu, Some(1));
206
207        // Left arrow moves back
208        handler.handle_key_event(&key(KeyCode::Left), &mut ctx);
209        assert_eq!(handler.state.active_menu, Some(0));
210    }
211
212    #[test]
213    fn test_menu_escape_closes() {
214        let menus = create_test_menus();
215        let mut state = MenuState::for_testing();
216        state.open_menu(0);
217
218        let mut handler = MenuInputHandler::new(&mut state, &menus);
219        let mut ctx = InputContext::new();
220
221        handler.handle_key_event(&key(KeyCode::Esc), &mut ctx);
222        assert!(ctx
223            .deferred_actions
224            .iter()
225            .any(|a| matches!(a, DeferredAction::CloseMenu)));
226    }
227
228    #[test]
229    fn test_menu_enter_executes() {
230        let menus = create_test_menus();
231        let mut state = MenuState::for_testing();
232        state.open_menu(0);
233        state.highlighted_item = Some(0); // "New" action
234
235        let mut handler = MenuInputHandler::new(&mut state, &menus);
236        let mut ctx = InputContext::new();
237
238        handler.handle_key_event(&key(KeyCode::Enter), &mut ctx);
239        assert!(ctx.deferred_actions.iter().any(|a| matches!(
240            a,
241            DeferredAction::ExecuteMenuAction { action, .. } if action == "new_file"
242        )));
243    }
244
245    #[test]
246    fn test_menu_is_modal_when_active() {
247        let menus = create_test_menus();
248        let mut state = MenuState::for_testing();
249
250        let handler = MenuInputHandler::new(&mut state, &menus);
251        assert!(!handler.is_modal());
252
253        state.open_menu(0);
254        let handler = MenuInputHandler::new(&mut state, &menus);
255        assert!(handler.is_modal());
256    }
257
258    #[test]
259    fn test_menu_ignored_when_inactive() {
260        let menus = create_test_menus();
261        let mut state = MenuState::for_testing();
262
263        let mut handler = MenuInputHandler::new(&mut state, &menus);
264        let mut ctx = InputContext::new();
265
266        let result = handler.handle_key_event(&key(KeyCode::Down), &mut ctx);
267        assert_eq!(result, InputResult::Ignored);
268    }
269}