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, KeyContext};
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        // — including the floating widget panel (Orchestrator picker,
25        // new-session form, plugin overlays), which is the editor-wide
26        // modal owner of the keyboard while it's up. Without this skip,
27        // a terminal-buffer-active window with `terminal_mode=true` would
28        // route keys to the PTY child even when the user's keystrokes
29        // are meant for the picker on top of it.
30        let in_modal = self.is_prompting()
31            || self.global_popups.is_visible()
32            || self.active_state().popups.is_visible()
33            || self.menu_state.active_menu.is_some()
34            || self.settings_state.as_ref().is_some_and(|s| s.visible)
35            || self.calibration_wizard.is_some()
36            || self.keybinding_editor.is_some()
37            || self.floating_widget_panel.is_some();
38
39        if in_modal {
40            return None;
41        }
42
43        // Handle terminal mode input
44        if self.active_window().terminal_mode {
45            // If the user navigated away from the terminal buffer (e.g. opened
46            // Review Diff via the command palette), the active buffer is no
47            // longer a terminal. Exit terminal mode so the new buffer's
48            // keybindings work.
49            if !self
50                .active_window()
51                .is_terminal_buffer(self.active_buffer())
52            {
53                self.active_window_mut().terminal_mode = false;
54                self.active_window_mut().key_context =
55                    crate::input::keybindings::KeyContext::Normal;
56                return None; // fall through to normal input dispatch
57            }
58            // Keyboard focus has been explicitly handed to the file
59            // explorer (issue #2029, sub-issue 1). Skip the PTY route
60            // even though `terminal_mode` is still set, so arrow keys
61            // reach the explorer instead of being swallowed by the
62            // shell. The `terminal_mode` flag is cleared up front by
63            // `take_focus_for_file_explorer`; this is a belt-and-braces
64            // guard against any async path that re-enables
65            // `terminal_mode` while file-explorer focus is legitimate.
66            if matches!(
67                self.active_window().key_context,
68                crate::input::keybindings::KeyContext::FileExplorer
69            ) {
70                return None;
71            }
72            // Plugin commands flagged `terminalBypass: true` (via
73            // `editor.registerCommand(..., { terminalBypass: true })`)
74            // resolve to actions that must reach the editor even
75            // when a terminal pane owns the keyboard — that's how
76            // bound shortcuts to commands like `Orchestrator: Open`
77            // stay reachable from inside `top`/`htop`/a shell.
78            // Resolve the key against the regular (Normal) context;
79            // if it's a registered bypass action, dispatch it and
80            // return *before* the terminal handler claims the key.
81            // Builtin UI actions (CommandPalette, QuickOpen, …)
82            // still flow through `TerminalModeInputHandler`'s own
83            // `is_terminal_ui_action` allowlist below.
84            let bypass_action = {
85                let keybindings = self.keybindings.read().unwrap();
86                let action = keybindings.resolve(event, KeyContext::Normal);
87                if self
88                    .command_registry
89                    .read()
90                    .unwrap()
91                    .is_terminal_bypass_action(&action)
92                {
93                    Some(action)
94                } else {
95                    None
96                }
97            };
98            if let Some(action) = bypass_action {
99                if let Err(e) = self.handle_action(action) {
100                    tracing::warn!("terminal-bypass action failed: {e}");
101                }
102                return Some(InputResult::Consumed);
103            }
104            let mut ctx = InputContext::new();
105            let keyboard_capture = self.active_window().keyboard_capture;
106            let keybindings = self.keybindings.read().unwrap();
107            let mut handler = TerminalModeInputHandler::new(keyboard_capture, &keybindings);
108            let result = handler.dispatch_input(event, &mut ctx);
109            drop(keybindings);
110            self.process_deferred_actions(ctx);
111            return Some(result);
112        }
113
114        // Check for keys that should re-enter terminal mode from scrollback view.
115        // Any plain character key exits scrollback and is forwarded to the terminal.
116        if self
117            .active_window()
118            .is_terminal_buffer(self.active_buffer())
119            && should_enter_terminal_mode(event)
120        {
121            self.enter_terminal_mode();
122            // Forward the key to the terminal so the user's input isn't lost
123            self.active_window_mut()
124                .send_terminal_key(event.code, event.modifiers);
125            return Some(InputResult::Consumed);
126        }
127
128        None
129    }
130
131    /// Dispatch input to the appropriate modal handler.
132    ///
133    /// Returns `Some(InputResult)` if a modal handled the input,
134    /// `None` if no modal is active and input should be handled normally.
135    pub fn dispatch_modal_input(&mut self, event: &KeyEvent) -> Option<InputResult> {
136        let mut ctx = InputContext::new();
137
138        // Settings has highest priority
139        if let Some(ref mut settings) = self.settings_state {
140            if settings.visible {
141                let result = settings.dispatch_input(event, &mut ctx);
142                self.process_deferred_actions(ctx);
143                return Some(result);
144            }
145        }
146
147        // Keybinding editor is next
148        if self.keybinding_editor.is_some() {
149            let result = self.handle_keybinding_editor_input(event);
150            return Some(result);
151        }
152
153        // Calibration wizard is next (modal, blocks all other input)
154        if self.calibration_wizard.is_some() {
155            let result = self.handle_calibration_input(event);
156            return Some(result);
157        }
158
159        // Menu is next
160        if self.menu_state.active_menu.is_some() {
161            let all_menus: Vec<crate::config::Menu> = self
162                .menus
163                .menus
164                .iter()
165                .chain(self.menu_state.plugin_menus.iter())
166                .cloned()
167                .collect();
168
169            let mut handler = MenuInputHandler::new(&mut self.menu_state, &all_menus);
170            let result = handler.dispatch_input(event, &mut ctx);
171            self.process_deferred_actions(ctx);
172            return Some(result);
173        }
174
175        // Prompt is next
176        if self.active_window().prompt.is_some() {
177            // Check for Alt+key keybindings in Prompt context first
178            // Use resolve_in_context_only to bypass Global bindings (like menu mnemonics)
179            // This allows Prompt-specific Alt+key bindings (like encoding toggle) to work
180            if event
181                .modifiers
182                .contains(crossterm::event::KeyModifiers::ALT)
183            {
184                if let crossterm::event::KeyCode::Char(_) = event.code {
185                    let prompt_action = self.keybindings.read().unwrap().resolve_in_context_only(
186                        event,
187                        crate::input::keybindings::KeyContext::Prompt,
188                    );
189                    if let Some(action) = prompt_action {
190                        // For file browser actions, route to handle_file_open_action
191                        if self.is_file_open_active() && self.handle_file_open_action(&action) {
192                            return Some(InputResult::Consumed);
193                        }
194                        // For other prompt actions, use handle_action
195                        if let Err(e) = self.handle_action(action) {
196                            tracing::warn!("Prompt action failed: {}", e);
197                        }
198                        return Some(InputResult::Consumed);
199                    }
200                }
201            }
202
203            // File browser prompts use FileBrowserInputHandler
204            if self.is_file_open_active() {
205                let active_window_id = self.active_window;
206                let __win = self
207                    .windows
208                    .get_mut(&active_window_id)
209                    .expect("active window present");
210                if let (Some(ref mut file_state), Some(ref mut prompt)) =
211                    (&mut __win.file_open_state, &mut __win.prompt)
212                {
213                    let mut handler = FileBrowserInputHandler::new(file_state, prompt);
214                    let result = handler.dispatch_input(event, &mut ctx);
215                    self.process_deferred_actions(ctx);
216                    return Some(result);
217                }
218            }
219
220            // QueryReplaceConfirm prompts use QueryReplaceConfirmInputHandler
221            use crate::view::prompt::PromptType;
222            let is_query_replace_confirm = self
223                .active_window()
224                .prompt
225                .as_ref()
226                .is_some_and(|p| p.prompt_type == PromptType::QueryReplaceConfirm);
227            if is_query_replace_confirm {
228                let mut handler = QueryReplaceConfirmInputHandler::new();
229                let result = handler.dispatch_input(event, &mut ctx);
230                self.process_deferred_actions(ctx);
231                return Some(result);
232            }
233
234            // Universal Search overlay focus ring: Tab/Shift+Tab move focus
235            // between the query input and the scope toggles; Space/Enter
236            // activate the focused toggle. Intercepted before the prompt's own
237            // input handling so Tab doesn't fall through to other behaviour.
238            if let Some(result) = self.handle_overlay_toolbar_key(event) {
239                return Some(result);
240            }
241
242            if let Some(ref mut prompt) = self.active_window_mut().prompt {
243                let result = prompt.dispatch_input(event, &mut ctx);
244                // Only return and process deferred actions if the prompt handled the input
245                // If Ignored, fall through to check global keybindings
246                if result != InputResult::Ignored {
247                    self.process_deferred_actions(ctx);
248                    return Some(result);
249                }
250            }
251        }
252
253        // Editor-pane popups (global + buffer) belong to the editor pane and
254        // must not capture input when the file explorer is the focused pane.
255        // Mirrors the priority encoded in `get_key_context()` via the same
256        // `popups_capture_keys()` predicate so the two paths cannot drift —
257        // one source of truth for "is the popup eligible to eat this key?".
258        if self.popups_capture_keys() {
259            // Completion popups consult the keybinding resolver in the
260            // `Completion` context first, so accept/dismiss can be remapped
261            // via the keybinding editor. Falls through to the popup's own
262            // handler for everything else (type-to-filter, navigation, etc.).
263            if let Some(action) = self.resolve_completion_popup_action(event) {
264                self.process_deferred_actions(ctx);
265                if let Err(e) = self.handle_action(action) {
266                    tracing::warn!("Completion popup action failed: {}", e);
267                }
268                return Some(InputResult::Consumed);
269            }
270
271            // The workspace-trust prompt is a bespoke modal with its own keys
272            // (mnemonics select-and-confirm, Q quits, Esc is inert). Intercept
273            // before the generic popup handler so list type-to-filter etc.
274            // never swallow them.
275            if self.global_popups.top().is_some_and(|p| {
276                matches!(
277                    p.resolver,
278                    crate::view::popup::PopupResolver::WorkspaceTrust
279                )
280            }) {
281                if let Some(result) = self.handle_workspace_trust_key(event) {
282                    return Some(result);
283                }
284            }
285
286            // Editor-level (global) popups take precedence over buffer popups
287            // so that plugin notifications stay focused even when the active
288            // buffer owns its own popup stack.
289            if self.global_popups.is_visible() {
290                let result = self.global_popups.dispatch_input(event, &mut ctx);
291                self.process_deferred_actions(ctx);
292                if result != InputResult::Ignored {
293                    return Some(result);
294                }
295                // Re-check visibility — the dispatch may have queued a
296                // ClosePopup that the deferred-action processor has now fired.
297                return None;
298            }
299
300            // Popup is next
301            if self.active_state().popups.is_visible() {
302                let result = self
303                    .active_state_mut()
304                    .popups
305                    .dispatch_input(event, &mut ctx);
306                self.process_deferred_actions(ctx);
307                // If the popup handler returned Ignored (e.g., non-word
308                // character, Ctrl+key, arrow keys), fall through to normal
309                // input handling. The deferred ClosePopup action was already
310                // processed above.
311                if result != InputResult::Ignored {
312                    return Some(result);
313                }
314            }
315        }
316
317        None
318    }
319
320    /// Process deferred actions collected during input handling.
321    pub fn process_deferred_actions(&mut self, ctx: InputContext) {
322        // Set status message if provided
323        if let Some(msg) = ctx.status_message {
324            self.set_status_message(msg);
325        }
326
327        // Process each deferred action
328        for action in ctx.deferred_actions {
329            if let Err(e) = self.execute_deferred_action(action) {
330                self.set_status_message(
331                    t!("error.deferred_action", error = e.to_string()).to_string(),
332                );
333            }
334        }
335    }
336
337    /// Execute a single deferred action.
338    fn execute_deferred_action(&mut self, action: DeferredAction) -> AnyhowResult<()> {
339        match action {
340            // Settings actions
341            DeferredAction::CloseSettings { save } => {
342                if save {
343                    self.save_settings();
344                }
345                self.close_settings(false);
346            }
347            DeferredAction::PasteToSettings => {
348                if let Some(text) = self.clipboard.paste() {
349                    if !text.is_empty() {
350                        if let Some(settings) = &mut self.settings_state {
351                            if let Some(dialog) = settings.entry_dialog_mut() {
352                                dialog.insert_str(&text);
353                            }
354                        }
355                    }
356                }
357            }
358            DeferredAction::OpenConfigFile { layer } => {
359                self.open_config_file(layer)?;
360            }
361
362            // Menu actions
363            DeferredAction::CloseMenu => {
364                self.close_menu_with_auto_hide();
365            }
366            DeferredAction::ExecuteMenuAction { action, args } => {
367                // Convert menu action to keybinding Action and execute
368                if let Some(kb_action) = self.menu_action_to_action(&action, args) {
369                    self.handle_action(kb_action)?;
370                }
371            }
372
373            // Prompt actions
374            DeferredAction::ClosePrompt => {
375                self.cancel_prompt();
376            }
377            DeferredAction::ConfirmPrompt => {
378                self.handle_action(Action::PromptConfirm)?;
379            }
380            DeferredAction::UpdatePromptSuggestions => {
381                self.update_prompt_suggestions();
382            }
383            DeferredAction::PromptHistoryPrev => {
384                self.prompt_history_prev();
385            }
386            DeferredAction::PromptHistoryNext => {
387                self.prompt_history_next();
388            }
389            DeferredAction::PreviewThemeFromPrompt => {
390                if let Some(prompt) = &self.active_window_mut().prompt {
391                    if matches!(
392                        prompt.prompt_type,
393                        crate::view::prompt::PromptType::SelectTheme { .. }
394                    ) {
395                        let theme_name = prompt.input.clone();
396                        self.preview_theme(&theme_name);
397                    }
398                }
399            }
400            DeferredAction::PromptSelectionChanged { selected_index } => {
401                // Fire hook for plugin prompts so they can update live preview
402                let plugin_custom_type =
403                    self.active_window()
404                        .prompt
405                        .as_ref()
406                        .and_then(|p| match &p.prompt_type {
407                            crate::view::prompt::PromptType::Plugin { custom_type } => {
408                                Some(custom_type.clone())
409                            }
410                            _ => None,
411                        });
412                if let Some(custom_type) = plugin_custom_type {
413                    self.plugin_manager.read().unwrap().run_hook(
414                        "prompt_selection_changed",
415                        crate::services::plugins::hooks::HookArgs::PromptSelectionChanged {
416                            prompt_type: custom_type.clone(),
417                            selected_index,
418                        },
419                    );
420                }
421            }
422
423            // Popup actions
424            DeferredAction::ClosePopup => {
425                // Route through handle_popup_cancel so popup-specific
426                // cleanup runs (e.g. the LSP auto-prompt needs to mark
427                // the language as prompted and drop the pending queue
428                // entry — otherwise the render-time drain would just
429                // re-open the popup on the next frame, defeating Esc).
430                self.handle_popup_cancel();
431            }
432            DeferredAction::ConfirmPopup => {
433                self.handle_action(Action::PopupConfirm)?;
434            }
435            DeferredAction::PopupTypeChar(c) => {
436                self.handle_popup_type_char(c);
437            }
438            DeferredAction::PopupBackspace => {
439                self.handle_popup_backspace();
440            }
441            DeferredAction::CopyToClipboard(text) => {
442                self.clipboard.copy(text);
443                self.set_status_message(t!("clipboard.copied").to_string());
444            }
445
446            // Generic action execution
447            DeferredAction::ExecuteAction(kb_action) => {
448                self.handle_action(kb_action)?;
449            }
450
451            // Character insertion with suggestion update
452            DeferredAction::InsertCharAndUpdate(c) => {
453                if let Some(ref mut prompt) = self.active_window_mut().prompt {
454                    prompt.insert_char(c);
455                }
456                self.update_prompt_suggestions();
457            }
458
459            // File browser actions
460            DeferredAction::FileBrowserSelectPrev => {
461                if let Some(state) = &mut self.active_window_mut().file_open_state {
462                    state.select_prev();
463                }
464            }
465            DeferredAction::FileBrowserSelectNext => {
466                if let Some(state) = &mut self.active_window_mut().file_open_state {
467                    state.select_next();
468                }
469            }
470            DeferredAction::FileBrowserPageUp => {
471                if let Some(state) = &mut self.active_window_mut().file_open_state {
472                    state.page_up(10);
473                }
474            }
475            DeferredAction::FileBrowserPageDown => {
476                if let Some(state) = &mut self.active_window_mut().file_open_state {
477                    state.page_down(10);
478                }
479            }
480            DeferredAction::FileBrowserConfirm => {
481                // Must call handle_file_open_action directly to get proper
482                // file browser behavior (e.g., project switch triggering restart)
483                self.handle_file_open_action(&Action::PromptConfirm);
484            }
485            DeferredAction::FileBrowserAcceptSuggestion => {
486                self.handle_file_open_action(&Action::PromptAcceptSuggestion);
487            }
488            DeferredAction::FileBrowserGoParent => {
489                // Navigate to parent directory
490                let parent = self
491                    .active_window_mut()
492                    .file_open_state
493                    .as_ref()
494                    .and_then(|s| s.current_dir.parent())
495                    .map(|p| p.to_path_buf());
496                if let Some(parent_path) = parent {
497                    self.load_file_open_directory(parent_path);
498                }
499            }
500            DeferredAction::FileBrowserUpdateFilter => {
501                self.update_file_open_filter();
502            }
503            DeferredAction::FileBrowserToggleHidden => {
504                self.file_open_toggle_hidden();
505            }
506
507            // Interactive replace actions
508            DeferredAction::InteractiveReplaceKey(c) => {
509                self.handle_interactive_replace_key(c)?;
510            }
511            DeferredAction::CancelInteractiveReplace => {
512                self.cancel_prompt();
513                self.active_window_mut().interactive_replace_state = None;
514            }
515
516            // Terminal mode actions
517            DeferredAction::ToggleKeyboardCapture => {
518                self.active_window_mut().keyboard_capture =
519                    !self.active_window_mut().keyboard_capture;
520                if self.active_window_mut().keyboard_capture {
521                    self.set_status_message(
522                        "Keyboard capture ON - all keys go to terminal (F9 to toggle)".to_string(),
523                    );
524                } else {
525                    self.set_status_message(
526                        "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
527                    );
528                }
529            }
530            DeferredAction::SendTerminalKey(code, modifiers) => {
531                self.active_window_mut().send_terminal_key(code, modifiers);
532            }
533            DeferredAction::SendTerminalMouse {
534                col,
535                row,
536                kind,
537                modifiers,
538            } => {
539                self.active_window_mut()
540                    .send_terminal_mouse(col, row, kind, modifiers);
541            }
542            DeferredAction::ExitTerminalMode { explicit } => {
543                self.active_window_mut().terminal_mode = false;
544                self.active_window_mut().key_context =
545                    crate::input::keybindings::KeyContext::Normal;
546                if explicit {
547                    // User explicitly exited - don't auto-resume when switching back
548                    let buf = self.active_buffer();
549                    self.active_window_mut().terminal_mode_resume.remove(&buf);
550                    {
551                        let __b = self.active_buffer();
552                        self.active_window_mut().sync_terminal_to_buffer(__b);
553                    };
554                    self.set_status_message(
555                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
556                    );
557                }
558            }
559            DeferredAction::EnterScrollbackMode => {
560                self.active_window_mut().terminal_mode = false;
561                self.active_window_mut().key_context =
562                    crate::input::keybindings::KeyContext::Normal;
563                {
564                    let __b = self.active_buffer();
565                    self.active_window_mut().sync_terminal_to_buffer(__b);
566                };
567                self.set_status_message(
568                    "Scrollback mode - use PageUp/Down to scroll (Ctrl+Space to resume)"
569                        .to_string(),
570                );
571                // Scroll up using normal buffer scrolling
572                self.handle_action(Action::MovePageUp)?;
573            }
574            DeferredAction::EnterTerminalMode => {
575                self.enter_terminal_mode();
576            }
577        }
578
579        Ok(())
580    }
581
582    /// Convert a menu action string to a keybinding Action.
583    fn menu_action_to_action(
584        &self,
585        action_name: &str,
586        args: std::collections::HashMap<String, serde_json::Value>,
587    ) -> Option<Action> {
588        // Try to parse as a built-in action first
589        if let Some(action) = Action::from_str(action_name, &args) {
590            return Some(action);
591        }
592
593        // Otherwise treat as a plugin action
594        Some(Action::PluginAction(action_name.to_string()))
595    }
596
597    /// Navigate to previous history entry in prompt.
598    fn prompt_history_prev(&mut self) {
599        // Get the prompt type and current input
600        let prompt_info = self
601            .active_window()
602            .prompt
603            .as_ref()
604            .map(|p| (p.prompt_type.clone(), p.input.clone()));
605
606        if let Some((prompt_type, current_input)) = prompt_info {
607            // Get the history key for this prompt type
608            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
609                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
610                    if let Some(entry) = history.navigate_prev(&current_input) {
611                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
612                            prompt.set_input(entry);
613                        }
614                    }
615                }
616            }
617        }
618    }
619
620    /// Navigate to next history entry in prompt.
621    fn prompt_history_next(&mut self) {
622        let prompt_type = self
623            .active_window()
624            .prompt
625            .as_ref()
626            .map(|p| p.prompt_type.clone());
627
628        if let Some(prompt_type) = prompt_type {
629            // Get the history key for this prompt type
630            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
631                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
632                    if let Some(entry) = history.navigate_next() {
633                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
634                            prompt.set_input(entry);
635                        }
636                    }
637                }
638            }
639        }
640    }
641
642    /// Ordered toggle keys of the active overlay's widget toolbar (render
643    /// order). Drives the focus ring. Empty when there's no toolbar.
644    fn overlay_toolbar_keys(&self) -> Vec<String> {
645        self.active_chrome()
646            .prompt_toolbar_hits
647            .iter()
648            .map(|(k, _)| k.clone())
649            .collect()
650    }
651
652    /// Advance (or retreat) the overlay focus ring: input → toggle0 → … →
653    /// toggleN → input. No-op (returns false) unless an overlay prompt with a
654    /// toolbar is active.
655    fn cycle_overlay_focus(&mut self, forward: bool) -> bool {
656        if !self.overlay_prompt_active() {
657            return false;
658        }
659        let has_toolbar = self
660            .active_window()
661            .prompt
662            .as_ref()
663            .is_some_and(|p| p.toolbar_widget.is_some());
664        if !has_toolbar {
665            return false;
666        }
667        let keys = self.overlay_toolbar_keys();
668        if keys.is_empty() {
669            return false;
670        }
671        let cur = self
672            .active_window()
673            .prompt
674            .as_ref()
675            .and_then(|p| p.toolbar_focus.clone());
676        // Ring includes the input as the `None` slot.
677        let next: Option<String> = match cur {
678            None => Some(if forward {
679                keys[0].clone()
680            } else {
681                keys[keys.len() - 1].clone()
682            }),
683            Some(k) => match keys.iter().position(|x| x == &k) {
684                Some(i) if forward => keys.get(i + 1).cloned(), // None past the end → input
685                Some(i) => {
686                    if i == 0 {
687                        None
688                    } else {
689                        keys.get(i - 1).cloned()
690                    }
691                }
692                None => None, // stale key → input
693            },
694        };
695        if let Some(p) = self.active_window_mut().prompt.as_mut() {
696            p.toolbar_focus = next;
697        }
698        true
699    }
700
701    /// Fire the focused toolbar control's toggle. The host owns the checked
702    /// state, so this flips it and emits a `widget_event` (see
703    /// `toggle_overlay_toolbar_widget`); the plugin reacts.
704    fn activate_focused_overlay_toggle(&mut self) {
705        let key = self
706            .active_window()
707            .prompt
708            .as_ref()
709            .and_then(|p| p.toolbar_focus.clone());
710        if let Some(key) = key {
711            self.toggle_overlay_toolbar_widget(&key);
712        }
713    }
714
715    /// Activate the overlay toolbar control with `key` and emit a
716    /// `widget_event` so the plugin can react. For a `Toggle` the host owns
717    /// the checked state — it flips it in place and emits `toggle`
718    /// (`{checked}`). For a `Button` it emits `activate` (`{}`). Shared by
719    /// mouse clicks, Space/Enter on the focused control, and the
720    /// `toggleOverlayToolbarWidget` plugin API — one host path for every way
721    /// a control can be triggered.
722    pub(crate) fn toggle_overlay_toolbar_widget(&mut self, key: &str) {
723        if key.is_empty() {
724            return;
725        }
726        // Resolve what event to emit, flipping a toggle's checked state in
727        // place. `None` → the key isn't a toggle/button (no-op).
728        let event: Option<(&'static str, serde_json::Value)> = {
729            let Some(prompt) = self.active_window_mut().prompt.as_mut() else {
730                return;
731            };
732            let Some(spec) = prompt.toolbar_widget.as_mut() else {
733                return;
734            };
735            match crate::widgets::find_widget_by_key(spec, key) {
736                Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
737                    let nv = !*checked;
738                    crate::widgets::set_toggle_checked_in_spec(spec, key, nv);
739                    Some(("toggle", serde_json::json!({ "checked": nv })))
740                }
741                Some(fresh_core::api::WidgetSpec::Button { .. }) => {
742                    Some(("activate", serde_json::json!({})))
743                }
744                _ => None,
745            }
746        };
747        let Some((event_type, payload)) = event else {
748            return;
749        };
750        #[cfg(feature = "plugins")]
751        {
752            let pm = self.plugin_manager.read().unwrap();
753            if pm.has_hook_handlers("widget_event") {
754                pm.run_hook(
755                    "widget_event",
756                    crate::services::plugins::hooks::HookArgs::WidgetEvent {
757                        panel_id: 0,
758                        widget_key: key.to_string(),
759                        event_type: event_type.to_string(),
760                        payload,
761                    },
762                );
763            }
764        }
765        #[cfg(not(feature = "plugins"))]
766        {
767            let _ = (event_type, payload);
768        }
769    }
770
771    /// Handle a key for the overlay's toolbar focus ring. Returns
772    /// `Some(Consumed)` when it owns the key, `None` to let normal prompt
773    /// handling proceed (also resets focus to the input when the user starts
774    /// typing, so typing always edits the query).
775    fn handle_overlay_toolbar_key(&mut self, event: &KeyEvent) -> Option<InputResult> {
776        use crossterm::event::{KeyCode, KeyModifiers};
777        if !self.overlay_prompt_active() {
778            return None;
779        }
780        let has_toolbar = self
781            .active_window()
782            .prompt
783            .as_ref()
784            .is_some_and(|p| p.toolbar_widget.is_some());
785        if !has_toolbar {
786            return None;
787        }
788        let focused = self
789            .active_window()
790            .prompt
791            .as_ref()
792            .is_some_and(|p| p.toolbar_focus.is_some());
793        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
794        match event.code {
795            KeyCode::BackTab => {
796                self.cycle_overlay_focus(false);
797                Some(InputResult::Consumed)
798            }
799            KeyCode::Tab => {
800                self.cycle_overlay_focus(!shift);
801                Some(InputResult::Consumed)
802            }
803            KeyCode::Char(' ') | KeyCode::Enter if focused => {
804                self.activate_focused_overlay_toggle();
805                Some(InputResult::Consumed)
806            }
807            // Navigating the result list (or typing) returns focus to the
808            // query input, then falls through so the navigation / character
809            // insertion happens — and Enter afterwards opens the highlighted
810            // result rather than re-activating a control.
811            KeyCode::Up
812            | KeyCode::Down
813            | KeyCode::PageUp
814            | KeyCode::PageDown
815            | KeyCode::Char(_)
816                if focused =>
817            {
818                if let Some(p) = self.active_window_mut().prompt.as_mut() {
819                    p.toolbar_focus = None;
820                }
821                None
822            }
823            _ => None,
824        }
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn test_deferred_action_close_menu() {
834        // This is a basic structure test - full integration tests
835        // would require a complete Editor setup
836        let action = DeferredAction::CloseMenu;
837        assert!(matches!(action, DeferredAction::CloseMenu));
838    }
839
840    #[test]
841    fn test_deferred_action_execute_menu_action() {
842        let action = DeferredAction::ExecuteMenuAction {
843            action: "save".to_string(),
844            args: std::collections::HashMap::new(),
845        };
846        if let DeferredAction::ExecuteMenuAction { action: name, .. } = action {
847            assert_eq!(name, "save");
848        } else {
849            panic!("Expected ExecuteMenuAction");
850        }
851    }
852}