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