fresh/input/handler.rs
1//! Hierarchical Input Handling System
2//!
3//! This module provides a tree-based input dispatch system where input events
4//! flow through a hierarchy of handlers. The design follows these principles:
5//!
6//! 1. **Leaf-first, bubble up**: Input is dispatched to the deepest focused
7//! element first. If not consumed, it bubbles up to parents.
8//!
9//! 2. **Explicit consumption**: Handlers return `InputResult::Consumed` to stop
10//! propagation or `InputResult::Ignored` to let parents try.
11//!
12//! 3. **Modals consume by default**: Modal dialogs (Settings, Prompts) should
13//! return `Consumed` for unhandled keys to prevent input leakage.
14//!
15//! 4. **No capture phase**: Unlike DOM events, there's no capture phase.
16//! This keeps the model simple and predictable.
17//!
18//! ## Example
19//!
20//! ```ignore
21//! impl InputHandler for MyPanel {
22//! fn handle_input(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
23//! // Let focused child try first
24//! if let Some(child) = self.focused_child_mut() {
25//! if child.handle_input(event, ctx) == InputResult::Consumed {
26//! return InputResult::Consumed;
27//! }
28//! }
29//!
30//! // Handle at this level
31//! match event.code {
32//! KeyCode::Up => { self.move_up(); InputResult::Consumed }
33//! KeyCode::Down => { self.move_down(); InputResult::Consumed }
34//! _ => InputResult::Ignored // Let parent handle
35//! }
36//! }
37//! }
38//! ```
39
40use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
41
42/// Result of handling an input event.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum InputResult {
45 /// The input was handled - stop propagation.
46 Consumed,
47 /// The input was not handled - try parent.
48 Ignored,
49}
50
51impl InputResult {
52 /// Returns true if the input was consumed.
53 pub fn is_consumed(self) -> bool {
54 self == InputResult::Consumed
55 }
56
57 /// Combines two results - consumed if either is consumed.
58 pub fn or(self, other: InputResult) -> InputResult {
59 if self == InputResult::Consumed || other == InputResult::Consumed {
60 InputResult::Consumed
61 } else {
62 InputResult::Ignored
63 }
64 }
65}
66
67/// Context passed to input handlers, providing access to shared state.
68#[derive(Default)]
69pub struct InputContext {
70 /// Status message to display (set by handlers).
71 pub status_message: Option<String>,
72 /// Actions to execute after input handling (for deferred operations).
73 pub deferred_actions: Vec<DeferredAction>,
74}
75
76impl InputContext {
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 pub fn set_status(&mut self, msg: impl Into<String>) {
82 self.status_message = Some(msg.into());
83 }
84
85 pub fn defer(&mut self, action: DeferredAction) {
86 self.deferred_actions.push(action);
87 }
88}
89
90/// Actions that need to be executed after input handling completes.
91/// These are operations that require mutable access to Editor.
92#[derive(Debug, Clone)]
93pub enum DeferredAction {
94 // Settings actions
95 CloseSettings {
96 save: bool,
97 },
98 /// Paste text from clipboard into the active settings input
99 PasteToSettings,
100 /// Open the config file for the specified layer in the editor
101 OpenConfigFile {
102 layer: crate::config_io::ConfigLayer,
103 },
104
105 // Menu actions
106 CloseMenu,
107 ExecuteMenuAction {
108 action: String,
109 args: std::collections::HashMap<String, serde_json::Value>,
110 },
111
112 // Prompt actions
113 ClosePrompt,
114 ConfirmPrompt,
115 UpdatePromptSuggestions,
116 PromptHistoryPrev,
117 PromptHistoryNext,
118 /// Preview theme from the current prompt input (for SelectTheme)
119 PreviewThemeFromPrompt,
120 /// Notify plugin that prompt selection changed (for live preview in Live Grep, etc.)
121 PromptSelectionChanged {
122 selected_index: usize,
123 },
124
125 // Popup actions
126 ClosePopup,
127 ConfirmPopup,
128 /// Type a character while completion popup is open (for type-to-filter)
129 PopupTypeChar(char),
130 /// Backspace while completion popup is open (for type-to-filter)
131 PopupBackspace,
132 /// Copy text to clipboard (from popup text selection)
133 CopyToClipboard(String),
134
135 // File browser actions
136 FileBrowserSelectPrev,
137 FileBrowserSelectNext,
138 FileBrowserPageUp,
139 FileBrowserPageDown,
140 FileBrowserConfirm,
141 FileBrowserAcceptSuggestion,
142 FileBrowserGoParent,
143 FileBrowserUpdateFilter,
144 FileBrowserToggleHidden,
145
146 // Interactive replace actions
147 InteractiveReplaceKey(char),
148 CancelInteractiveReplace,
149
150 // Terminal mode actions
151 ToggleKeyboardCapture,
152 SendTerminalKey(crossterm::event::KeyCode, crossterm::event::KeyModifiers),
153 ExitTerminalMode {
154 explicit: bool,
155 },
156 EnterScrollbackMode,
157 EnterTerminalMode,
158
159 // Generic action execution
160 ExecuteAction(crate::input::keybindings::Action),
161
162 // Insert character (for prompts that need to update suggestions)
163 InsertCharAndUpdate(char),
164}
165
166/// Trait for elements that can handle input events.
167///
168/// Implementors should:
169/// 1. First delegate to `focused_child_mut()` if it exists
170/// 2. Handle keys relevant to this element
171/// 3. Return `Consumed` or `Ignored` appropriately
172/// 4. Modal elements should return `Consumed` for unhandled keys
173pub trait InputHandler {
174 /// Handle a key event. Returns whether the event was consumed.
175 fn handle_key_event(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult;
176
177 /// Get the currently focused child handler, if any.
178 fn focused_child(&self) -> Option<&dyn InputHandler> {
179 None
180 }
181
182 /// Get the currently focused child handler mutably, if any.
183 fn focused_child_mut(&mut self) -> Option<&mut dyn InputHandler> {
184 None
185 }
186
187 /// Whether this handler is modal (consumes all unhandled input).
188 fn is_modal(&self) -> bool {
189 false
190 }
191
192 /// Dispatch input through this handler and its children.
193 /// This is the main entry point - it handles the bubble-up logic.
194 fn dispatch_input(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
195 // First, let the deepest focused child try
196 if let Some(child) = self.focused_child_mut() {
197 let result = child.dispatch_input(event, ctx);
198 if result == InputResult::Consumed {
199 return InputResult::Consumed;
200 }
201 }
202
203 // Child didn't consume, try this handler
204 let result = self.handle_key_event(event, ctx);
205 if result == InputResult::Consumed {
206 return InputResult::Consumed;
207 }
208
209 // If modal, consume even if we didn't handle it
210 if self.is_modal() {
211 return InputResult::Consumed;
212 }
213
214 InputResult::Ignored
215 }
216}
217
218/// Helper to check for common key combinations.
219pub fn is_key(event: &KeyEvent, code: KeyCode) -> bool {
220 event.code == code && event.modifiers.is_empty()
221}
222
223pub fn is_key_with_ctrl(event: &KeyEvent, c: char) -> bool {
224 event.code == KeyCode::Char(c) && event.modifiers == KeyModifiers::CONTROL
225}
226
227pub fn is_key_with_shift(event: &KeyEvent, code: KeyCode) -> bool {
228 event.code == code && event.modifiers == KeyModifiers::SHIFT
229}
230
231pub fn is_key_with_alt(event: &KeyEvent, code: KeyCode) -> bool {
232 event.code == code && event.modifiers == KeyModifiers::ALT
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_input_result_or() {
241 assert_eq!(
242 InputResult::Consumed.or(InputResult::Consumed),
243 InputResult::Consumed
244 );
245 assert_eq!(
246 InputResult::Consumed.or(InputResult::Ignored),
247 InputResult::Consumed
248 );
249 assert_eq!(
250 InputResult::Ignored.or(InputResult::Consumed),
251 InputResult::Consumed
252 );
253 assert_eq!(
254 InputResult::Ignored.or(InputResult::Ignored),
255 InputResult::Ignored
256 );
257 }
258
259 #[test]
260 fn test_is_consumed() {
261 assert!(InputResult::Consumed.is_consumed());
262 assert!(!InputResult::Ignored.is_consumed());
263 }
264}