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(¤t_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}