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                            settings.paste_into_focused_text(&text);
370                        }
371                    }
372                }
373            }
374            DeferredAction::OpenConfigFile { layer } => {
375                self.open_config_file(layer)?;
376            }
377
378            // Menu actions
379            DeferredAction::CloseMenu => {
380                self.close_menu_with_auto_hide();
381            }
382            DeferredAction::ExecuteMenuAction { action, args } => {
383                // Convert menu action to keybinding Action and execute
384                if let Some(kb_action) = self.menu_action_to_action(&action, args) {
385                    self.handle_action(kb_action)?;
386                }
387            }
388
389            // Prompt actions
390            DeferredAction::ClosePrompt => {
391                self.cancel_prompt();
392            }
393            DeferredAction::ConfirmPrompt => {
394                self.handle_action(Action::PromptConfirm)?;
395            }
396            DeferredAction::UpdatePromptSuggestions => {
397                self.update_prompt_suggestions();
398            }
399            DeferredAction::PromptHistoryPrev => {
400                self.prompt_history_prev();
401            }
402            DeferredAction::PromptHistoryNext => {
403                self.prompt_history_next();
404            }
405            DeferredAction::PreviewThemeFromPrompt => {
406                if let Some(prompt) = &self.active_window_mut().prompt {
407                    if matches!(
408                        prompt.prompt_type,
409                        crate::view::prompt::PromptType::SelectTheme { .. }
410                    ) {
411                        let theme_name = prompt.input.clone();
412                        self.preview_theme(&theme_name);
413                    }
414                }
415            }
416            DeferredAction::PromptSelectionChanged { selected_index } => {
417                // Fire hook for plugin prompts so they can update live preview
418                let plugin_custom_type =
419                    self.active_window()
420                        .prompt
421                        .as_ref()
422                        .and_then(|p| match &p.prompt_type {
423                            crate::view::prompt::PromptType::Plugin { custom_type } => {
424                                Some(custom_type.clone())
425                            }
426                            _ => None,
427                        });
428                if let Some(custom_type) = plugin_custom_type {
429                    self.plugin_manager.read().unwrap().run_hook(
430                        "prompt_selection_changed",
431                        crate::services::plugins::hooks::HookArgs::PromptSelectionChanged {
432                            prompt_type: custom_type.clone(),
433                            selected_index,
434                        },
435                    );
436                }
437            }
438
439            // Popup actions
440            DeferredAction::ClosePopup => {
441                // Route through handle_popup_cancel so popup-specific
442                // cleanup runs (e.g. the LSP auto-prompt needs to mark
443                // the language as prompted and drop the pending queue
444                // entry — otherwise the render-time drain would just
445                // re-open the popup on the next frame, defeating Esc).
446                self.handle_popup_cancel();
447            }
448            DeferredAction::ConfirmPopup => {
449                self.handle_action(Action::PopupConfirm)?;
450            }
451            DeferredAction::PopupTypeChar(c) => {
452                self.handle_popup_type_char(c);
453            }
454            DeferredAction::PopupBackspace => {
455                self.handle_popup_backspace();
456            }
457            DeferredAction::CopyToClipboard(text) => {
458                self.clipboard.copy(text);
459                self.set_status_message(t!("clipboard.copied").to_string());
460            }
461
462            // Generic action execution
463            DeferredAction::ExecuteAction(kb_action) => {
464                self.handle_action(kb_action)?;
465            }
466
467            // Character insertion with suggestion update
468            DeferredAction::InsertCharAndUpdate(c) => {
469                if let Some(ref mut prompt) = self.active_window_mut().prompt {
470                    prompt.insert_char(c);
471                }
472                self.update_prompt_suggestions();
473            }
474
475            // File browser actions
476            DeferredAction::FileBrowserSelectPrev => {
477                if let Some(state) = &mut self.active_window_mut().file_open_state {
478                    state.select_prev();
479                }
480            }
481            DeferredAction::FileBrowserSelectNext => {
482                if let Some(state) = &mut self.active_window_mut().file_open_state {
483                    state.select_next();
484                }
485            }
486            DeferredAction::FileBrowserPageUp => {
487                if let Some(state) = &mut self.active_window_mut().file_open_state {
488                    state.page_up(10);
489                }
490            }
491            DeferredAction::FileBrowserPageDown => {
492                if let Some(state) = &mut self.active_window_mut().file_open_state {
493                    state.page_down(10);
494                }
495            }
496            DeferredAction::FileBrowserConfirm => {
497                // Must call handle_file_open_action directly to get proper
498                // file browser behavior (e.g., project switch triggering restart)
499                self.handle_file_open_action(&Action::PromptConfirm);
500            }
501            DeferredAction::FileBrowserAcceptSuggestion => {
502                self.handle_file_open_action(&Action::PromptAcceptSuggestion);
503            }
504            DeferredAction::FileBrowserGoParent => {
505                // Navigate to parent directory
506                let parent = self
507                    .active_window_mut()
508                    .file_open_state
509                    .as_ref()
510                    .and_then(|s| s.current_dir.parent())
511                    .map(|p| p.to_path_buf());
512                if let Some(parent_path) = parent {
513                    self.load_file_open_directory(parent_path);
514                }
515            }
516            DeferredAction::FileBrowserUpdateFilter => {
517                self.update_file_open_filter();
518            }
519            DeferredAction::FileBrowserToggleHidden => {
520                self.file_open_toggle_hidden();
521            }
522
523            // Interactive replace actions
524            DeferredAction::InteractiveReplaceKey(c) => {
525                self.handle_interactive_replace_key(c)?;
526            }
527            DeferredAction::CancelInteractiveReplace => {
528                self.cancel_prompt();
529                self.active_window_mut().interactive_replace_state = None;
530            }
531
532            // Terminal mode actions
533            DeferredAction::ToggleKeyboardCapture => {
534                self.active_window_mut().keyboard_capture =
535                    !self.active_window_mut().keyboard_capture;
536                if self.active_window_mut().keyboard_capture {
537                    self.set_status_message(
538                        "Keyboard capture ON - all keys go to terminal (F9 to toggle)".to_string(),
539                    );
540                } else {
541                    self.set_status_message(
542                        "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
543                    );
544                }
545            }
546            DeferredAction::SendTerminalKey(code, modifiers) => {
547                self.active_window_mut().send_terminal_key(code, modifiers);
548            }
549            DeferredAction::SendTerminalMouse {
550                col,
551                row,
552                kind,
553                modifiers,
554            } => {
555                self.active_window_mut()
556                    .send_terminal_mouse(col, row, kind, modifiers);
557            }
558            DeferredAction::ExitTerminalMode { explicit } => {
559                self.active_window_mut().terminal_mode = false;
560                self.active_window_mut().key_context =
561                    crate::input::keybindings::KeyContext::Normal;
562                if explicit {
563                    // User explicitly exited — remember scrollback so refocus
564                    // doesn't auto-resume into live mode.
565                    let buf = self.active_buffer();
566                    self.active_window_mut().set_terminal_interaction_mode(
567                        buf,
568                        crate::app::window::TerminalInteractionMode::Scrollback,
569                    );
570                    self.active_window_mut().sync_terminal_to_buffer(buf);
571                    self.set_status_message(
572                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
573                    );
574                }
575            }
576            DeferredAction::EnterScrollbackMode => {
577                // Dropping to scrollback is a mode change: remember it so the
578                // terminal stays read-only the next time it is focused.
579                let __b = self.active_buffer();
580                self.active_window_mut().set_terminal_interaction_mode(
581                    __b,
582                    crate::app::window::TerminalInteractionMode::Scrollback,
583                );
584                self.active_window_mut().terminal_mode = false;
585                self.active_window_mut().key_context =
586                    crate::input::keybindings::KeyContext::Normal;
587                self.active_window_mut().sync_terminal_to_buffer(__b);
588                self.set_status_message(
589                    "Scrollback mode - use PageUp/Down to scroll (Ctrl+Space to resume)"
590                        .to_string(),
591                );
592                // Scroll up using normal buffer scrolling
593                self.handle_action(Action::MovePageUp)?;
594            }
595            DeferredAction::EnterTerminalMode => {
596                self.enter_terminal_mode();
597            }
598        }
599
600        Ok(())
601    }
602
603    /// Convert a menu action string to a keybinding Action.
604    fn menu_action_to_action(
605        &self,
606        action_name: &str,
607        args: std::collections::HashMap<String, serde_json::Value>,
608    ) -> Option<Action> {
609        // Try to parse as a built-in action first
610        if let Some(action) = Action::from_str(action_name, &args) {
611            return Some(action);
612        }
613
614        // Otherwise treat as a plugin action
615        Some(Action::PluginAction(action_name.to_string()))
616    }
617
618    /// Navigate to previous history entry in prompt.
619    fn prompt_history_prev(&mut self) {
620        // Get the prompt type and current input
621        let prompt_info = self
622            .active_window()
623            .prompt
624            .as_ref()
625            .map(|p| (p.prompt_type.clone(), p.input.clone()));
626
627        if let Some((prompt_type, current_input)) = prompt_info {
628            // Get the history key for this prompt type
629            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
630                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
631                    if let Some(entry) = history.navigate_prev(&current_input) {
632                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
633                            prompt.set_input(entry);
634                        }
635                    }
636                }
637            }
638        }
639    }
640
641    /// Navigate to next history entry in prompt.
642    fn prompt_history_next(&mut self) {
643        let prompt_type = self
644            .active_window()
645            .prompt
646            .as_ref()
647            .map(|p| p.prompt_type.clone());
648
649        if let Some(prompt_type) = prompt_type {
650            // Get the history key for this prompt type
651            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
652                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
653                    if let Some(entry) = history.navigate_next() {
654                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
655                            prompt.set_input(entry);
656                        }
657                    }
658                }
659            }
660        }
661    }
662
663    /// Ordered toggle keys of the active overlay's widget toolbar (render
664    /// order). Drives the focus ring. Empty when there's no toolbar.
665    fn overlay_toolbar_keys(&self) -> Vec<String> {
666        self.active_chrome()
667            .prompt_toolbar_hits
668            .iter()
669            .map(|(k, _)| k.clone())
670            .collect()
671    }
672
673    /// Advance (or retreat) the overlay focus ring: input → toggle0 → … →
674    /// toggleN → input. No-op (returns false) unless an overlay prompt with a
675    /// toolbar is active.
676    fn cycle_overlay_focus(&mut self, forward: bool) -> bool {
677        if !self.overlay_prompt_active() {
678            return false;
679        }
680        let has_toolbar = self
681            .active_window()
682            .prompt
683            .as_ref()
684            .is_some_and(|p| p.toolbar_widget.is_some());
685        if !has_toolbar {
686            return false;
687        }
688        let keys = self.overlay_toolbar_keys();
689        if keys.is_empty() {
690            return false;
691        }
692        let cur = self
693            .active_window()
694            .prompt
695            .as_ref()
696            .and_then(|p| p.toolbar_focus.clone());
697        // Ring includes the input as the `None` slot.
698        let next: Option<String> = match cur {
699            None => Some(if forward {
700                keys[0].clone()
701            } else {
702                keys[keys.len() - 1].clone()
703            }),
704            Some(k) => match keys.iter().position(|x| x == &k) {
705                Some(i) if forward => keys.get(i + 1).cloned(), // None past the end → input
706                Some(i) => {
707                    if i == 0 {
708                        None
709                    } else {
710                        keys.get(i - 1).cloned()
711                    }
712                }
713                None => None, // stale key → input
714            },
715        };
716        if let Some(p) = self.active_window_mut().prompt.as_mut() {
717            p.toolbar_focus = next;
718        }
719        true
720    }
721
722    /// Fire the focused toolbar control's toggle. The host owns the checked
723    /// state, so this flips it and emits a `widget_event` (see
724    /// `toggle_overlay_toolbar_widget`); the plugin reacts.
725    fn activate_focused_overlay_toggle(&mut self) {
726        let key = self
727            .active_window()
728            .prompt
729            .as_ref()
730            .and_then(|p| p.toolbar_focus.clone());
731        if let Some(key) = key {
732            self.toggle_overlay_toolbar_widget(&key);
733        }
734    }
735
736    /// Activate the overlay toolbar control with `key` and emit a
737    /// `widget_event` so the plugin can react. For a `Toggle` the host owns
738    /// the checked state — it flips it in place and emits `toggle`
739    /// (`{checked}`). For a `Button` it emits `activate` (`{}`). Shared by
740    /// mouse clicks, Space/Enter on the focused control, and the
741    /// `toggleOverlayToolbarWidget` plugin API — one host path for every way
742    /// a control can be triggered.
743    pub(crate) fn toggle_overlay_toolbar_widget(&mut self, key: &str) {
744        if key.is_empty() {
745            return;
746        }
747        // Resolve what event to emit, flipping a toggle's checked state in
748        // place. `None` → the key isn't a toggle/button (no-op).
749        let event: Option<(&'static str, serde_json::Value)> = {
750            let Some(prompt) = self.active_window_mut().prompt.as_mut() else {
751                return;
752            };
753            let Some(spec) = prompt.toolbar_widget.as_mut() else {
754                return;
755            };
756            match crate::widgets::find_widget_by_key(spec, key) {
757                Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
758                    let nv = !*checked;
759                    crate::widgets::set_toggle_checked_in_spec(spec, key, nv);
760                    Some(("toggle", serde_json::json!({ "checked": nv })))
761                }
762                Some(fresh_core::api::WidgetSpec::Button { .. }) => {
763                    Some(("activate", serde_json::json!({})))
764                }
765                _ => None,
766            }
767        };
768        let Some((event_type, payload)) = event else {
769            return;
770        };
771        #[cfg(feature = "plugins")]
772        {
773            // The overlay toolbar isn't a registry panel — it has no
774            // owner to target, so this broadcasts with panel_id 0.
775            let pm = self.plugin_manager.read().unwrap();
776            if pm.has_hook_handlers("widget_event") {
777                pm.run_hook(
778                    "widget_event",
779                    crate::services::plugins::hooks::HookArgs::WidgetEvent {
780                        panel_id: 0,
781                        widget_key: key.to_string(),
782                        event_type: event_type.to_string(),
783                        payload,
784                    },
785                );
786            }
787        }
788        #[cfg(not(feature = "plugins"))]
789        {
790            let _ = (event_type, payload);
791        }
792    }
793
794    /// Handle a key for the overlay's toolbar focus ring. Returns
795    /// `Some(Consumed)` when it owns the key, `None` to let normal prompt
796    /// handling proceed (also resets focus to the input when the user starts
797    /// typing, so typing always edits the query).
798    fn handle_overlay_toolbar_key(&mut self, event: &KeyEvent) -> Option<InputResult> {
799        use crossterm::event::{KeyCode, KeyModifiers};
800        if !self.overlay_prompt_active() {
801            return None;
802        }
803        let has_toolbar = self
804            .active_window()
805            .prompt
806            .as_ref()
807            .is_some_and(|p| p.toolbar_widget.is_some());
808        if !has_toolbar {
809            return None;
810        }
811        let focused = self
812            .active_window()
813            .prompt
814            .as_ref()
815            .is_some_and(|p| p.toolbar_focus.is_some());
816        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
817        match event.code {
818            KeyCode::BackTab => {
819                self.cycle_overlay_focus(false);
820                Some(InputResult::Consumed)
821            }
822            KeyCode::Tab => {
823                self.cycle_overlay_focus(!shift);
824                Some(InputResult::Consumed)
825            }
826            KeyCode::Char(' ') | KeyCode::Enter if focused => {
827                self.activate_focused_overlay_toggle();
828                Some(InputResult::Consumed)
829            }
830            // Navigating the result list (or typing) returns focus to the
831            // query input, then falls through so the navigation / character
832            // insertion happens — and Enter afterwards opens the highlighted
833            // result rather than re-activating a control.
834            KeyCode::Up
835            | KeyCode::Down
836            | KeyCode::PageUp
837            | KeyCode::PageDown
838            | KeyCode::Char(_)
839                if focused =>
840            {
841                if let Some(p) = self.active_window_mut().prompt.as_mut() {
842                    p.toolbar_focus = None;
843                }
844                None
845            }
846            _ => None,
847        }
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_deferred_action_close_menu() {
857        // This is a basic structure test - full integration tests
858        // would require a complete Editor setup
859        let action = DeferredAction::CloseMenu;
860        assert!(matches!(action, DeferredAction::CloseMenu));
861    }
862
863    #[test]
864    fn test_deferred_action_execute_menu_action() {
865        let action = DeferredAction::ExecuteMenuAction {
866            action: "save".to_string(),
867            args: std::collections::HashMap::new(),
868        };
869        if let DeferredAction::ExecuteMenuAction { action: name, .. } = action {
870            assert_eq!(name, "save");
871        } else {
872            panic!("Expected ExecuteMenuAction");
873        }
874    }
875}