Skip to main content

fresh/app/
input_dispatch.rs

1//! Input dispatch using the hierarchical InputHandler system.
2//!
3//! This module provides the bridge between Editor and the InputHandler trait,
4//! dispatching input to modal components and processing deferred actions.
5
6use super::terminal_input::{should_enter_terminal_mode, TerminalModeInputHandler};
7use super::Editor;
8use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
9use crate::input::keybindings::Action;
10use crate::view::file_browser_input::FileBrowserInputHandler;
11use crate::view::query_replace_input::QueryReplaceConfirmInputHandler;
12use crate::view::ui::MenuInputHandler;
13use anyhow::Result as AnyhowResult;
14use crossterm::event::KeyEvent;
15use rust_i18n::t;
16
17impl Editor {
18    /// Dispatch input when in terminal mode.
19    ///
20    /// Returns `Some(InputResult)` if terminal mode handled the input,
21    /// `None` if not in terminal mode or if a modal is active.
22    pub fn dispatch_terminal_input(&mut self, event: &KeyEvent) -> Option<InputResult> {
23        // Skip if we're in a prompt/popup (those need to handle keys normally)
24        let in_modal = self.is_prompting()
25            || self.active_state().popups.is_visible()
26            || self.menu_state.active_menu.is_some()
27            || self.settings_state.as_ref().is_some_and(|s| s.visible)
28            || self.calibration_wizard.is_some()
29            || self.keybinding_editor.is_some();
30
31        if in_modal {
32            return None;
33        }
34
35        // Handle terminal mode input
36        if self.terminal_mode {
37            // If the user navigated away from the terminal buffer (e.g. opened
38            // Review Diff via the command palette), the active buffer is no
39            // longer a terminal. Exit terminal mode so the new buffer's
40            // keybindings work.
41            if !self.is_terminal_buffer(self.active_buffer()) {
42                self.terminal_mode = false;
43                self.key_context = crate::input::keybindings::KeyContext::Normal;
44                return None; // fall through to normal input dispatch
45            }
46            let mut ctx = InputContext::new();
47            let keybindings = self.keybindings.read().unwrap();
48            let mut handler = TerminalModeInputHandler::new(self.keyboard_capture, &keybindings);
49            let result = handler.dispatch_input(event, &mut ctx);
50            drop(keybindings);
51            self.process_deferred_actions(ctx);
52            return Some(result);
53        }
54
55        // Check for keys that should re-enter terminal mode from scrollback view.
56        // Any plain character key exits scrollback and is forwarded to the terminal.
57        if self.is_terminal_buffer(self.active_buffer()) && should_enter_terminal_mode(event) {
58            self.enter_terminal_mode();
59            // Forward the key to the terminal so the user's input isn't lost
60            self.send_terminal_key(event.code, event.modifiers);
61            return Some(InputResult::Consumed);
62        }
63
64        None
65    }
66
67    /// Dispatch input to the appropriate modal handler.
68    ///
69    /// Returns `Some(InputResult)` if a modal handled the input,
70    /// `None` if no modal is active and input should be handled normally.
71    pub fn dispatch_modal_input(&mut self, event: &KeyEvent) -> Option<InputResult> {
72        let mut ctx = InputContext::new();
73
74        // Settings has highest priority
75        if let Some(ref mut settings) = self.settings_state {
76            if settings.visible {
77                let result = settings.dispatch_input(event, &mut ctx);
78                self.process_deferred_actions(ctx);
79                return Some(result);
80            }
81        }
82
83        // Keybinding editor is next
84        if self.keybinding_editor.is_some() {
85            let result = self.handle_keybinding_editor_input(event);
86            return Some(result);
87        }
88
89        // Calibration wizard is next (modal, blocks all other input)
90        if self.calibration_wizard.is_some() {
91            let result = self.handle_calibration_input(event);
92            return Some(result);
93        }
94
95        // Menu is next
96        if self.menu_state.active_menu.is_some() {
97            let all_menus: Vec<crate::config::Menu> = self
98                .menus
99                .menus
100                .iter()
101                .chain(self.menu_state.plugin_menus.iter())
102                .cloned()
103                .collect();
104
105            let mut handler = MenuInputHandler::new(&mut self.menu_state, &all_menus);
106            let result = handler.dispatch_input(event, &mut ctx);
107            self.process_deferred_actions(ctx);
108            return Some(result);
109        }
110
111        // Prompt is next
112        if self.prompt.is_some() {
113            // Check for Alt+key keybindings in Prompt context first
114            // Use resolve_in_context_only to bypass Global bindings (like menu mnemonics)
115            // This allows Prompt-specific Alt+key bindings (like encoding toggle) to work
116            if event
117                .modifiers
118                .contains(crossterm::event::KeyModifiers::ALT)
119            {
120                if let crossterm::event::KeyCode::Char(_) = event.code {
121                    let prompt_action = self.keybindings.read().unwrap().resolve_in_context_only(
122                        event,
123                        crate::input::keybindings::KeyContext::Prompt,
124                    );
125                    if let Some(action) = prompt_action {
126                        // For file browser actions, route to handle_file_open_action
127                        if self.is_file_open_active() && self.handle_file_open_action(&action) {
128                            return Some(InputResult::Consumed);
129                        }
130                        // For other prompt actions, use handle_action
131                        if let Err(e) = self.handle_action(action) {
132                            tracing::warn!("Prompt action failed: {}", e);
133                        }
134                        return Some(InputResult::Consumed);
135                    }
136                }
137            }
138
139            // File browser prompts use FileBrowserInputHandler
140            if self.is_file_open_active() {
141                if let (Some(ref mut file_state), Some(ref mut prompt)) =
142                    (&mut self.file_open_state, &mut self.prompt)
143                {
144                    let mut handler = FileBrowserInputHandler::new(file_state, prompt);
145                    let result = handler.dispatch_input(event, &mut ctx);
146                    self.process_deferred_actions(ctx);
147                    return Some(result);
148                }
149            }
150
151            // QueryReplaceConfirm prompts use QueryReplaceConfirmInputHandler
152            use crate::view::prompt::PromptType;
153            let is_query_replace_confirm = self
154                .prompt
155                .as_ref()
156                .is_some_and(|p| p.prompt_type == PromptType::QueryReplaceConfirm);
157            if is_query_replace_confirm {
158                let mut handler = QueryReplaceConfirmInputHandler::new();
159                let result = handler.dispatch_input(event, &mut ctx);
160                self.process_deferred_actions(ctx);
161                return Some(result);
162            }
163
164            if let Some(ref mut prompt) = self.prompt {
165                let result = prompt.dispatch_input(event, &mut ctx);
166                // Only return and process deferred actions if the prompt handled the input
167                // If Ignored, fall through to check global keybindings
168                if result != InputResult::Ignored {
169                    self.process_deferred_actions(ctx);
170                    return Some(result);
171                }
172            }
173        }
174
175        // Popup is next
176        if self.active_state().popups.is_visible() {
177            let result = self
178                .active_state_mut()
179                .popups
180                .dispatch_input(event, &mut ctx);
181            self.process_deferred_actions(ctx);
182            // If the popup handler returned Ignored (e.g., non-word character,
183            // Ctrl+key, arrow keys), fall through to normal input handling.
184            // The deferred ClosePopup action was already processed above.
185            if result != InputResult::Ignored {
186                return Some(result);
187            }
188        }
189
190        None
191    }
192
193    /// Process deferred actions collected during input handling.
194    pub fn process_deferred_actions(&mut self, ctx: InputContext) {
195        // Set status message if provided
196        if let Some(msg) = ctx.status_message {
197            self.set_status_message(msg);
198        }
199
200        // Process each deferred action
201        for action in ctx.deferred_actions {
202            if let Err(e) = self.execute_deferred_action(action) {
203                self.set_status_message(
204                    t!("error.deferred_action", error = e.to_string()).to_string(),
205                );
206            }
207        }
208    }
209
210    /// Execute a single deferred action.
211    fn execute_deferred_action(&mut self, action: DeferredAction) -> AnyhowResult<()> {
212        match action {
213            // Settings actions
214            DeferredAction::CloseSettings { save } => {
215                if save {
216                    self.save_settings();
217                }
218                self.close_settings(false);
219            }
220            DeferredAction::PasteToSettings => {
221                if let Some(text) = self.clipboard.paste() {
222                    if !text.is_empty() {
223                        if let Some(settings) = &mut self.settings_state {
224                            if let Some(dialog) = settings.entry_dialog_mut() {
225                                dialog.insert_str(&text);
226                            }
227                        }
228                    }
229                }
230            }
231            DeferredAction::OpenConfigFile { layer } => {
232                self.open_config_file(layer)?;
233            }
234
235            // Menu actions
236            DeferredAction::CloseMenu => {
237                self.close_menu_with_auto_hide();
238            }
239            DeferredAction::ExecuteMenuAction { action, args } => {
240                // Convert menu action to keybinding Action and execute
241                if let Some(kb_action) = self.menu_action_to_action(&action, args) {
242                    self.handle_action(kb_action)?;
243                }
244            }
245
246            // Prompt actions
247            DeferredAction::ClosePrompt => {
248                self.cancel_prompt();
249            }
250            DeferredAction::ConfirmPrompt => {
251                self.handle_action(Action::PromptConfirm)?;
252            }
253            DeferredAction::UpdatePromptSuggestions => {
254                self.update_prompt_suggestions();
255            }
256            DeferredAction::PromptHistoryPrev => {
257                self.prompt_history_prev();
258            }
259            DeferredAction::PromptHistoryNext => {
260                self.prompt_history_next();
261            }
262            DeferredAction::PreviewThemeFromPrompt => {
263                if let Some(prompt) = &self.prompt {
264                    if matches!(
265                        prompt.prompt_type,
266                        crate::view::prompt::PromptType::SelectTheme { .. }
267                    ) {
268                        let theme_name = prompt.input.clone();
269                        self.preview_theme(&theme_name);
270                    }
271                }
272            }
273            DeferredAction::PromptSelectionChanged { selected_index } => {
274                // Fire hook for plugin prompts so they can update live preview
275                if let Some(prompt) = &self.prompt {
276                    if let crate::view::prompt::PromptType::Plugin { custom_type } =
277                        &prompt.prompt_type
278                    {
279                        self.plugin_manager.run_hook(
280                            "prompt_selection_changed",
281                            crate::services::plugins::hooks::HookArgs::PromptSelectionChanged {
282                                prompt_type: custom_type.clone(),
283                                selected_index,
284                            },
285                        );
286                    }
287                }
288            }
289
290            // Popup actions
291            DeferredAction::ClosePopup => {
292                self.hide_popup();
293            }
294            DeferredAction::ConfirmPopup => {
295                self.handle_action(Action::PopupConfirm)?;
296            }
297            DeferredAction::PopupTypeChar(c) => {
298                self.handle_popup_type_char(c);
299            }
300            DeferredAction::PopupBackspace => {
301                self.handle_popup_backspace();
302            }
303            DeferredAction::CopyToClipboard(text) => {
304                self.clipboard.copy(text);
305                self.set_status_message(t!("clipboard.copied").to_string());
306            }
307
308            // Generic action execution
309            DeferredAction::ExecuteAction(kb_action) => {
310                self.handle_action(kb_action)?;
311            }
312
313            // Character insertion with suggestion update
314            DeferredAction::InsertCharAndUpdate(c) => {
315                if let Some(ref mut prompt) = self.prompt {
316                    prompt.insert_char(c);
317                }
318                self.update_prompt_suggestions();
319            }
320
321            // File browser actions
322            DeferredAction::FileBrowserSelectPrev => {
323                if let Some(state) = &mut self.file_open_state {
324                    state.select_prev();
325                }
326            }
327            DeferredAction::FileBrowserSelectNext => {
328                if let Some(state) = &mut self.file_open_state {
329                    state.select_next();
330                }
331            }
332            DeferredAction::FileBrowserPageUp => {
333                if let Some(state) = &mut self.file_open_state {
334                    state.page_up(10);
335                }
336            }
337            DeferredAction::FileBrowserPageDown => {
338                if let Some(state) = &mut self.file_open_state {
339                    state.page_down(10);
340                }
341            }
342            DeferredAction::FileBrowserConfirm => {
343                // Must call handle_file_open_action directly to get proper
344                // file browser behavior (e.g., project switch triggering restart)
345                self.handle_file_open_action(&Action::PromptConfirm);
346            }
347            DeferredAction::FileBrowserAcceptSuggestion => {
348                self.handle_file_open_action(&Action::PromptAcceptSuggestion);
349            }
350            DeferredAction::FileBrowserGoParent => {
351                // Navigate to parent directory
352                let parent = self
353                    .file_open_state
354                    .as_ref()
355                    .and_then(|s| s.current_dir.parent())
356                    .map(|p| p.to_path_buf());
357                if let Some(parent_path) = parent {
358                    self.load_file_open_directory(parent_path);
359                }
360            }
361            DeferredAction::FileBrowserUpdateFilter => {
362                self.update_file_open_filter();
363            }
364            DeferredAction::FileBrowserToggleHidden => {
365                self.file_open_toggle_hidden();
366            }
367
368            // Interactive replace actions
369            DeferredAction::InteractiveReplaceKey(c) => {
370                self.handle_interactive_replace_key(c)?;
371            }
372            DeferredAction::CancelInteractiveReplace => {
373                self.cancel_prompt();
374                self.interactive_replace_state = None;
375            }
376
377            // Terminal mode actions
378            DeferredAction::ToggleKeyboardCapture => {
379                self.keyboard_capture = !self.keyboard_capture;
380                if self.keyboard_capture {
381                    self.set_status_message(
382                        "Keyboard capture ON - all keys go to terminal (F9 to toggle)".to_string(),
383                    );
384                } else {
385                    self.set_status_message(
386                        "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
387                    );
388                }
389            }
390            DeferredAction::SendTerminalKey(code, modifiers) => {
391                self.send_terminal_key(code, modifiers);
392            }
393            DeferredAction::SendTerminalMouse {
394                col,
395                row,
396                kind,
397                modifiers,
398            } => {
399                self.send_terminal_mouse(col, row, kind, modifiers);
400            }
401            DeferredAction::ExitTerminalMode { explicit } => {
402                self.terminal_mode = false;
403                self.key_context = crate::input::keybindings::KeyContext::Normal;
404                if explicit {
405                    // User explicitly exited - don't auto-resume when switching back
406                    self.terminal_mode_resume.remove(&self.active_buffer());
407                    self.sync_terminal_to_buffer(self.active_buffer());
408                    self.set_status_message(
409                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
410                    );
411                }
412            }
413            DeferredAction::EnterScrollbackMode => {
414                self.terminal_mode = false;
415                self.key_context = crate::input::keybindings::KeyContext::Normal;
416                self.sync_terminal_to_buffer(self.active_buffer());
417                self.set_status_message(
418                    "Scrollback mode - use PageUp/Down to scroll (Ctrl+Space to resume)"
419                        .to_string(),
420                );
421                // Scroll up using normal buffer scrolling
422                self.handle_action(Action::MovePageUp)?;
423            }
424            DeferredAction::EnterTerminalMode => {
425                self.enter_terminal_mode();
426            }
427        }
428
429        Ok(())
430    }
431
432    /// Convert a menu action string to a keybinding Action.
433    fn menu_action_to_action(
434        &self,
435        action_name: &str,
436        args: std::collections::HashMap<String, serde_json::Value>,
437    ) -> Option<Action> {
438        // Try to parse as a built-in action first
439        if let Some(action) = Action::from_str(action_name, &args) {
440            return Some(action);
441        }
442
443        // Otherwise treat as a plugin action
444        Some(Action::PluginAction(action_name.to_string()))
445    }
446
447    /// Navigate to previous history entry in prompt.
448    fn prompt_history_prev(&mut self) {
449        // Get the prompt type and current input
450        let prompt_info = self
451            .prompt
452            .as_ref()
453            .map(|p| (p.prompt_type.clone(), p.input.clone()));
454
455        if let Some((prompt_type, current_input)) = prompt_info {
456            // Get the history key for this prompt type
457            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
458                if let Some(history) = self.prompt_histories.get_mut(&key) {
459                    if let Some(entry) = history.navigate_prev(&current_input) {
460                        if let Some(ref mut prompt) = self.prompt {
461                            prompt.set_input(entry);
462                        }
463                    }
464                }
465            }
466        }
467    }
468
469    /// Navigate to next history entry in prompt.
470    fn prompt_history_next(&mut self) {
471        let prompt_type = self.prompt.as_ref().map(|p| p.prompt_type.clone());
472
473        if let Some(prompt_type) = prompt_type {
474            // Get the history key for this prompt type
475            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
476                if let Some(history) = self.prompt_histories.get_mut(&key) {
477                    if let Some(entry) = history.navigate_next() {
478                        if let Some(ref mut prompt) = self.prompt {
479                            prompt.set_input(entry);
480                        }
481                    }
482                }
483            }
484        }
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_deferred_action_close_menu() {
494        // This is a basic structure test - full integration tests
495        // would require a complete Editor setup
496        let action = DeferredAction::CloseMenu;
497        assert!(matches!(action, DeferredAction::CloseMenu));
498    }
499
500    #[test]
501    fn test_deferred_action_execute_menu_action() {
502        let action = DeferredAction::ExecuteMenuAction {
503            action: "save".to_string(),
504            args: std::collections::HashMap::new(),
505        };
506        if let DeferredAction::ExecuteMenuAction { action: name, .. } = action {
507            assert_eq!(name, "save");
508        } else {
509            panic!("Expected ExecuteMenuAction");
510        }
511    }
512}