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