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