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