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 - don't auto-resume when switching back
564                    let buf = self.active_buffer();
565                    self.active_window_mut().terminal_mode_resume.remove(&buf);
566                    {
567                        let __b = self.active_buffer();
568                        self.active_window_mut().sync_terminal_to_buffer(__b);
569                    };
570                    self.set_status_message(
571                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
572                    );
573                }
574            }
575            DeferredAction::EnterScrollbackMode => {
576                self.active_window_mut().terminal_mode = false;
577                self.active_window_mut().key_context =
578                    crate::input::keybindings::KeyContext::Normal;
579                {
580                    let __b = self.active_buffer();
581                    self.active_window_mut().sync_terminal_to_buffer(__b);
582                };
583                self.set_status_message(
584                    "Scrollback mode - use PageUp/Down to scroll (Ctrl+Space to resume)"
585                        .to_string(),
586                );
587                // Scroll up using normal buffer scrolling
588                self.handle_action(Action::MovePageUp)?;
589            }
590            DeferredAction::EnterTerminalMode => {
591                self.enter_terminal_mode();
592            }
593        }
594
595        Ok(())
596    }
597
598    /// Convert a menu action string to a keybinding Action.
599    fn menu_action_to_action(
600        &self,
601        action_name: &str,
602        args: std::collections::HashMap<String, serde_json::Value>,
603    ) -> Option<Action> {
604        // Try to parse as a built-in action first
605        if let Some(action) = Action::from_str(action_name, &args) {
606            return Some(action);
607        }
608
609        // Otherwise treat as a plugin action
610        Some(Action::PluginAction(action_name.to_string()))
611    }
612
613    /// Navigate to previous history entry in prompt.
614    fn prompt_history_prev(&mut self) {
615        // Get the prompt type and current input
616        let prompt_info = self
617            .active_window()
618            .prompt
619            .as_ref()
620            .map(|p| (p.prompt_type.clone(), p.input.clone()));
621
622        if let Some((prompt_type, current_input)) = prompt_info {
623            // Get the history key for this prompt type
624            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
625                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
626                    if let Some(entry) = history.navigate_prev(&current_input) {
627                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
628                            prompt.set_input(entry);
629                        }
630                    }
631                }
632            }
633        }
634    }
635
636    /// Navigate to next history entry in prompt.
637    fn prompt_history_next(&mut self) {
638        let prompt_type = self
639            .active_window()
640            .prompt
641            .as_ref()
642            .map(|p| p.prompt_type.clone());
643
644        if let Some(prompt_type) = prompt_type {
645            // Get the history key for this prompt type
646            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
647                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
648                    if let Some(entry) = history.navigate_next() {
649                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
650                            prompt.set_input(entry);
651                        }
652                    }
653                }
654            }
655        }
656    }
657
658    /// Ordered toggle keys of the active overlay's widget toolbar (render
659    /// order). Drives the focus ring. Empty when there's no toolbar.
660    fn overlay_toolbar_keys(&self) -> Vec<String> {
661        self.active_chrome()
662            .prompt_toolbar_hits
663            .iter()
664            .map(|(k, _)| k.clone())
665            .collect()
666    }
667
668    /// Advance (or retreat) the overlay focus ring: input → toggle0 → … →
669    /// toggleN → input. No-op (returns false) unless an overlay prompt with a
670    /// toolbar is active.
671    fn cycle_overlay_focus(&mut self, forward: bool) -> bool {
672        if !self.overlay_prompt_active() {
673            return false;
674        }
675        let has_toolbar = self
676            .active_window()
677            .prompt
678            .as_ref()
679            .is_some_and(|p| p.toolbar_widget.is_some());
680        if !has_toolbar {
681            return false;
682        }
683        let keys = self.overlay_toolbar_keys();
684        if keys.is_empty() {
685            return false;
686        }
687        let cur = self
688            .active_window()
689            .prompt
690            .as_ref()
691            .and_then(|p| p.toolbar_focus.clone());
692        // Ring includes the input as the `None` slot.
693        let next: Option<String> = match cur {
694            None => Some(if forward {
695                keys[0].clone()
696            } else {
697                keys[keys.len() - 1].clone()
698            }),
699            Some(k) => match keys.iter().position(|x| x == &k) {
700                Some(i) if forward => keys.get(i + 1).cloned(), // None past the end → input
701                Some(i) => {
702                    if i == 0 {
703                        None
704                    } else {
705                        keys.get(i - 1).cloned()
706                    }
707                }
708                None => None, // stale key → input
709            },
710        };
711        if let Some(p) = self.active_window_mut().prompt.as_mut() {
712            p.toolbar_focus = next;
713        }
714        true
715    }
716
717    /// Fire the focused toolbar control's toggle. The host owns the checked
718    /// state, so this flips it and emits a `widget_event` (see
719    /// `toggle_overlay_toolbar_widget`); the plugin reacts.
720    fn activate_focused_overlay_toggle(&mut self) {
721        let key = self
722            .active_window()
723            .prompt
724            .as_ref()
725            .and_then(|p| p.toolbar_focus.clone());
726        if let Some(key) = key {
727            self.toggle_overlay_toolbar_widget(&key);
728        }
729    }
730
731    /// Activate the overlay toolbar control with `key` and emit a
732    /// `widget_event` so the plugin can react. For a `Toggle` the host owns
733    /// the checked state — it flips it in place and emits `toggle`
734    /// (`{checked}`). For a `Button` it emits `activate` (`{}`). Shared by
735    /// mouse clicks, Space/Enter on the focused control, and the
736    /// `toggleOverlayToolbarWidget` plugin API — one host path for every way
737    /// a control can be triggered.
738    pub(crate) fn toggle_overlay_toolbar_widget(&mut self, key: &str) {
739        if key.is_empty() {
740            return;
741        }
742        // Resolve what event to emit, flipping a toggle's checked state in
743        // place. `None` → the key isn't a toggle/button (no-op).
744        let event: Option<(&'static str, serde_json::Value)> = {
745            let Some(prompt) = self.active_window_mut().prompt.as_mut() else {
746                return;
747            };
748            let Some(spec) = prompt.toolbar_widget.as_mut() else {
749                return;
750            };
751            match crate::widgets::find_widget_by_key(spec, key) {
752                Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
753                    let nv = !*checked;
754                    crate::widgets::set_toggle_checked_in_spec(spec, key, nv);
755                    Some(("toggle", serde_json::json!({ "checked": nv })))
756                }
757                Some(fresh_core::api::WidgetSpec::Button { .. }) => {
758                    Some(("activate", serde_json::json!({})))
759                }
760                _ => None,
761            }
762        };
763        let Some((event_type, payload)) = event else {
764            return;
765        };
766        #[cfg(feature = "plugins")]
767        {
768            let pm = self.plugin_manager.read().unwrap();
769            if pm.has_hook_handlers("widget_event") {
770                pm.run_hook(
771                    "widget_event",
772                    crate::services::plugins::hooks::HookArgs::WidgetEvent {
773                        panel_id: 0,
774                        widget_key: key.to_string(),
775                        event_type: event_type.to_string(),
776                        payload,
777                    },
778                );
779            }
780        }
781        #[cfg(not(feature = "plugins"))]
782        {
783            let _ = (event_type, payload);
784        }
785    }
786
787    /// Handle a key for the overlay's toolbar focus ring. Returns
788    /// `Some(Consumed)` when it owns the key, `None` to let normal prompt
789    /// handling proceed (also resets focus to the input when the user starts
790    /// typing, so typing always edits the query).
791    fn handle_overlay_toolbar_key(&mut self, event: &KeyEvent) -> Option<InputResult> {
792        use crossterm::event::{KeyCode, KeyModifiers};
793        if !self.overlay_prompt_active() {
794            return None;
795        }
796        let has_toolbar = self
797            .active_window()
798            .prompt
799            .as_ref()
800            .is_some_and(|p| p.toolbar_widget.is_some());
801        if !has_toolbar {
802            return None;
803        }
804        let focused = self
805            .active_window()
806            .prompt
807            .as_ref()
808            .is_some_and(|p| p.toolbar_focus.is_some());
809        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
810        match event.code {
811            KeyCode::BackTab => {
812                self.cycle_overlay_focus(false);
813                Some(InputResult::Consumed)
814            }
815            KeyCode::Tab => {
816                self.cycle_overlay_focus(!shift);
817                Some(InputResult::Consumed)
818            }
819            KeyCode::Char(' ') | KeyCode::Enter if focused => {
820                self.activate_focused_overlay_toggle();
821                Some(InputResult::Consumed)
822            }
823            // Navigating the result list (or typing) returns focus to the
824            // query input, then falls through so the navigation / character
825            // insertion happens — and Enter afterwards opens the highlighted
826            // result rather than re-activating a control.
827            KeyCode::Up
828            | KeyCode::Down
829            | KeyCode::PageUp
830            | KeyCode::PageDown
831            | KeyCode::Char(_)
832                if focused =>
833            {
834                if let Some(p) = self.active_window_mut().prompt.as_mut() {
835                    p.toolbar_focus = None;
836                }
837                None
838            }
839            _ => None,
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn test_deferred_action_close_menu() {
850        // This is a basic structure test - full integration tests
851        // would require a complete Editor setup
852        let action = DeferredAction::CloseMenu;
853        assert!(matches!(action, DeferredAction::CloseMenu));
854    }
855
856    #[test]
857    fn test_deferred_action_execute_menu_action() {
858        let action = DeferredAction::ExecuteMenuAction {
859            action: "save".to_string(),
860            args: std::collections::HashMap::new(),
861        };
862        if let DeferredAction::ExecuteMenuAction { action: name, .. } = action {
863            assert_eq!(name, "save");
864        } else {
865            panic!("Expected ExecuteMenuAction");
866        }
867    }
868}