Skip to main content

fresh/view/
prompt_input.rs

1//! Input handling for the Prompt (minibuffer).
2//!
3//! Implements the InputHandler trait for Prompt, handling text editing,
4//! cursor movement, and suggestion navigation.
5
6use super::prompt::Prompt;
7use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10impl InputHandler for Prompt {
11    fn handle_key_event(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
12        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
13        let alt = event.modifiers.contains(KeyModifiers::ALT);
14        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
15
16        match event.code {
17            // Confirmation and cancellation
18            KeyCode::Enter => {
19                ctx.defer(DeferredAction::ConfirmPrompt);
20                InputResult::Consumed
21            }
22            KeyCode::Esc => {
23                ctx.defer(DeferredAction::ClosePrompt);
24                InputResult::Consumed
25            }
26
27            // Alt+key combinations should pass through to keybindings
28            KeyCode::Char(_) if alt => InputResult::Ignored,
29
30            // Character input (no modifiers or just shift)
31            KeyCode::Char(c) if !ctrl => {
32                // Delete any selection before inserting
33                if self.has_selection() {
34                    self.delete_selection();
35                }
36                if shift {
37                    self.insert_char(c.to_ascii_uppercase());
38                } else {
39                    self.insert_char(c);
40                }
41                ctx.defer(DeferredAction::UpdatePromptSuggestions);
42                InputResult::Consumed
43            }
44            KeyCode::Char(c) if ctrl => self.handle_ctrl_key(c, ctx),
45
46            // Deletion
47            KeyCode::Backspace if ctrl => {
48                self.delete_word_backward();
49                ctx.defer(DeferredAction::UpdatePromptSuggestions);
50                InputResult::Consumed
51            }
52            KeyCode::Backspace => {
53                if self.has_selection() {
54                    self.delete_selection();
55                } else {
56                    self.backspace();
57                }
58                ctx.defer(DeferredAction::UpdatePromptSuggestions);
59                InputResult::Consumed
60            }
61            KeyCode::Delete if ctrl => {
62                self.delete_word_forward();
63                ctx.defer(DeferredAction::UpdatePromptSuggestions);
64                InputResult::Consumed
65            }
66            KeyCode::Delete => {
67                if self.has_selection() {
68                    self.delete_selection();
69                } else {
70                    self.delete();
71                }
72                ctx.defer(DeferredAction::UpdatePromptSuggestions);
73                InputResult::Consumed
74            }
75
76            // Cursor movement
77            KeyCode::Left if ctrl && shift => {
78                self.move_word_left_selecting();
79                InputResult::Consumed
80            }
81            KeyCode::Left if ctrl => {
82                self.move_word_left();
83                InputResult::Consumed
84            }
85            KeyCode::Left if shift => {
86                self.move_left_selecting();
87                InputResult::Consumed
88            }
89            KeyCode::Left => {
90                self.clear_selection();
91                self.cursor_left();
92                InputResult::Consumed
93            }
94            KeyCode::Right if ctrl && shift => {
95                self.move_word_right_selecting();
96                InputResult::Consumed
97            }
98            KeyCode::Right if ctrl => {
99                self.move_word_right();
100                InputResult::Consumed
101            }
102            KeyCode::Right if shift => {
103                self.move_right_selecting();
104                InputResult::Consumed
105            }
106            KeyCode::Right => {
107                self.clear_selection();
108                self.cursor_right();
109                InputResult::Consumed
110            }
111            KeyCode::Home if shift => {
112                self.move_home_selecting();
113                InputResult::Consumed
114            }
115            KeyCode::Home => {
116                self.clear_selection();
117                self.move_to_start();
118                InputResult::Consumed
119            }
120            KeyCode::End if shift => {
121                self.move_end_selecting();
122                InputResult::Consumed
123            }
124            KeyCode::End => {
125                self.clear_selection();
126                self.move_to_end();
127                InputResult::Consumed
128            }
129
130            // Suggestion navigation
131            // TODO: Refactor to use callbacks - the prompt creator (e.g. SelectTheme, SelectLocale)
132            // should be able to register a callback for selection changes instead of having
133            // hardcoded prompt type checks here. This would make the suggestion UI more flexible
134            // and allow custom handling for any prompt type without modifying this code.
135            KeyCode::Up => {
136                if !self.suggestions.is_empty() {
137                    // Don't wrap around - stay at 0 if already at the beginning
138                    if let Some(selected) = self.selected_suggestion {
139                        let new_selected = if selected == 0 { 0 } else { selected - 1 };
140                        self.selected_suggestion = Some(new_selected);
141                        // For non-plugin prompts (except QuickOpen), or plugin prompts
142                        // with sync_input_on_navigate, update input to match selected suggestion
143                        let should_sync = self.sync_input_on_navigate
144                            || !matches!(
145                                self.prompt_type,
146                                crate::view::prompt::PromptType::Plugin { .. }
147                                    | crate::view::prompt::PromptType::QuickOpen
148                                    | crate::view::prompt::PromptType::LiveGrep
149                            );
150                        if should_sync {
151                            if let Some(suggestion) = self.suggestions.get(new_selected) {
152                                self.input = suggestion.get_value().to_string();
153                                self.cursor_pos = self.input.len();
154                                self.selection_anchor = Some(0);
155                            }
156                        }
157                        // For theme selection, trigger live preview
158                        if matches!(
159                            self.prompt_type,
160                            crate::view::prompt::PromptType::SelectTheme { .. }
161                        ) {
162                            ctx.defer(DeferredAction::PreviewThemeFromPrompt);
163                        }
164                        // For plugin prompts, notify about selection change (for live preview)
165                        if matches!(
166                            self.prompt_type,
167                            crate::view::prompt::PromptType::Plugin { .. }
168                        ) {
169                            ctx.defer(DeferredAction::PromptSelectionChanged {
170                                selected_index: new_selected,
171                            });
172                        }
173                    }
174                } else {
175                    // No suggestions - use history
176                    ctx.defer(DeferredAction::PromptHistoryPrev);
177                }
178                InputResult::Consumed
179            }
180            KeyCode::Down => {
181                if !self.suggestions.is_empty() {
182                    // Don't wrap around - stay at end if already at the last item
183                    if let Some(selected) = self.selected_suggestion {
184                        let new_selected = (selected + 1).min(self.suggestions.len() - 1);
185                        self.selected_suggestion = Some(new_selected);
186                        // For non-plugin prompts (except QuickOpen), or plugin prompts
187                        // with sync_input_on_navigate, update input to match selected suggestion
188                        let should_sync = self.sync_input_on_navigate
189                            || !matches!(
190                                self.prompt_type,
191                                crate::view::prompt::PromptType::Plugin { .. }
192                                    | crate::view::prompt::PromptType::QuickOpen
193                                    | crate::view::prompt::PromptType::LiveGrep
194                            );
195                        if should_sync {
196                            if let Some(suggestion) = self.suggestions.get(new_selected) {
197                                self.input = suggestion.get_value().to_string();
198                                self.cursor_pos = self.input.len();
199                                self.selection_anchor = Some(0);
200                            }
201                        }
202                        // For theme selection, trigger live preview
203                        if matches!(
204                            self.prompt_type,
205                            crate::view::prompt::PromptType::SelectTheme { .. }
206                        ) {
207                            ctx.defer(DeferredAction::PreviewThemeFromPrompt);
208                        }
209                        // For plugin prompts, notify about selection change (for live preview)
210                        if matches!(
211                            self.prompt_type,
212                            crate::view::prompt::PromptType::Plugin { .. }
213                        ) {
214                            ctx.defer(DeferredAction::PromptSelectionChanged {
215                                selected_index: new_selected,
216                            });
217                        }
218                    }
219                } else {
220                    // No suggestions - use history
221                    ctx.defer(DeferredAction::PromptHistoryNext);
222                }
223                InputResult::Consumed
224            }
225            KeyCode::PageUp => {
226                if let Some(selected) = self.selected_suggestion {
227                    self.selected_suggestion = Some(selected.saturating_sub(10));
228                }
229                InputResult::Consumed
230            }
231            KeyCode::PageDown => {
232                if let Some(selected) = self.selected_suggestion {
233                    let len = self.suggestions.len();
234                    let new_pos = selected + 10;
235                    self.selected_suggestion = Some(new_pos.min(len.saturating_sub(1)));
236                }
237                InputResult::Consumed
238            }
239
240            // Tab accepts suggestion
241            KeyCode::Tab => {
242                // In a floating-overlay prompt (Live Grep) the
243                // suggestion's `value` field is opaque (the Finder
244                // library uses indices like "0", "1", …) so accepting
245                // it would clobber the search query with garbage.
246                // Arrow keys already move the selection, so Tab is
247                // simply a no-op in overlay mode.
248                if self.overlay {
249                    return InputResult::Consumed;
250                }
251                if let Some(selected) = self.selected_suggestion {
252                    if let Some(suggestion) = self.suggestions.get(selected) {
253                        if !suggestion.disabled {
254                            let value = suggestion.get_value().to_string();
255                            // For QuickOpen mode, preserve the prefix character
256                            if matches!(
257                                self.prompt_type,
258                                crate::view::prompt::PromptType::QuickOpen
259                            ) {
260                                let prefix = self
261                                    .input
262                                    .chars()
263                                    .next()
264                                    .filter(|c| *c == '>' || *c == '#' || *c == ':');
265                                if let Some(p) = prefix {
266                                    self.input = format!("{}{}", p, value);
267                                } else {
268                                    self.input = value;
269                                }
270                            } else {
271                                self.input = value;
272                            }
273                            self.cursor_pos = self.input.len();
274                            self.clear_selection();
275                        }
276                    }
277                }
278                ctx.defer(DeferredAction::UpdatePromptSuggestions);
279                InputResult::Consumed
280            }
281
282            _ => InputResult::Consumed, // Modal - consume all unhandled keys
283        }
284    }
285
286    fn is_modal(&self) -> bool {
287        true
288    }
289}
290
291impl Prompt {
292    fn handle_ctrl_key(&mut self, c: char, ctx: &mut InputContext) -> InputResult {
293        match c {
294            'a' => {
295                // Select all
296                self.selection_anchor = Some(0);
297                self.cursor_pos = self.input.len();
298                InputResult::Consumed
299            }
300            'c' => {
301                // Copy - defer to Editor for clipboard access
302                ctx.defer(DeferredAction::ExecuteAction(
303                    crate::input::keybindings::Action::PromptCopy,
304                ));
305                InputResult::Consumed
306            }
307            'x' => {
308                // Cut - defer to Editor for clipboard access
309                ctx.defer(DeferredAction::ExecuteAction(
310                    crate::input::keybindings::Action::PromptCut,
311                ));
312                InputResult::Consumed
313            }
314            'v' => {
315                // Paste - defer to Editor for clipboard access
316                ctx.defer(DeferredAction::ExecuteAction(
317                    crate::input::keybindings::Action::PromptPaste,
318                ));
319                InputResult::Consumed
320            }
321            'k' => {
322                // Delete to end of line
323                self.delete_to_end();
324                ctx.defer(DeferredAction::UpdatePromptSuggestions);
325                InputResult::Consumed
326            }
327            'z' => {
328                // Undo the last input edit. Operates on the prompt's own
329                // history so undo edits the query box, not the underlying
330                // (modal-inaccessible) buffer. Consumed unconditionally so
331                // it never falls through to the global buffer undo.
332                if self.undo_input() {
333                    ctx.defer(DeferredAction::UpdatePromptSuggestions);
334                }
335                InputResult::Consumed
336            }
337            'y' => {
338                // Redo the last undone input edit.
339                if self.redo_input() {
340                    ctx.defer(DeferredAction::UpdatePromptSuggestions);
341                }
342                InputResult::Consumed
343            }
344            // Pass through other Ctrl+key combinations to global keybindings (e.g., Ctrl+P to toggle Quick Open)
345            _ => InputResult::Ignored,
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::view::prompt::PromptType;
354
355    fn key(code: KeyCode) -> KeyEvent {
356        KeyEvent::new(code, KeyModifiers::NONE)
357    }
358
359    fn key_with_ctrl(c: char) -> KeyEvent {
360        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
361    }
362
363    fn key_with_shift(code: KeyCode) -> KeyEvent {
364        KeyEvent::new(code, KeyModifiers::SHIFT)
365    }
366
367    #[test]
368    fn test_prompt_character_input() {
369        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
370        let mut ctx = InputContext::new();
371
372        prompt.handle_key_event(
373            &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
374            &mut ctx,
375        );
376        prompt.handle_key_event(
377            &KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
378            &mut ctx,
379        );
380
381        assert_eq!(prompt.input, "hi");
382        assert_eq!(prompt.cursor_pos, 2);
383    }
384
385    #[test]
386    fn test_prompt_backspace() {
387        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
388        prompt.input = "hello".to_string();
389        prompt.cursor_pos = 5;
390        let mut ctx = InputContext::new();
391
392        prompt.handle_key_event(&key(KeyCode::Backspace), &mut ctx);
393        assert_eq!(prompt.input, "hell");
394        assert_eq!(prompt.cursor_pos, 4);
395    }
396
397    #[test]
398    fn test_prompt_cursor_movement() {
399        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
400        prompt.input = "hello".to_string();
401        prompt.cursor_pos = 5;
402        let mut ctx = InputContext::new();
403
404        // Move to start
405        prompt.handle_key_event(&key(KeyCode::Home), &mut ctx);
406        assert_eq!(prompt.cursor_pos, 0);
407
408        // Move to end
409        prompt.handle_key_event(&key(KeyCode::End), &mut ctx);
410        assert_eq!(prompt.cursor_pos, 5);
411
412        // Move left
413        prompt.handle_key_event(&key(KeyCode::Left), &mut ctx);
414        assert_eq!(prompt.cursor_pos, 4);
415
416        // Move right
417        prompt.handle_key_event(&key(KeyCode::Right), &mut ctx);
418        assert_eq!(prompt.cursor_pos, 5);
419    }
420
421    #[test]
422    fn test_prompt_selection() {
423        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
424        prompt.input = "hello world".to_string();
425        prompt.cursor_pos = 0;
426        let mut ctx = InputContext::new();
427
428        // Select with Shift+Right
429        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
430        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
431        assert!(prompt.has_selection());
432        assert_eq!(prompt.selected_text(), Some("he".to_string()));
433
434        // Select all with Ctrl+A
435        prompt.handle_key_event(&key_with_ctrl('a'), &mut ctx);
436        assert_eq!(prompt.selected_text(), Some("hello world".to_string()));
437    }
438
439    #[test]
440    fn test_prompt_enter_confirms() {
441        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
442        let mut ctx = InputContext::new();
443
444        prompt.handle_key_event(&key(KeyCode::Enter), &mut ctx);
445        assert!(ctx
446            .deferred_actions
447            .iter()
448            .any(|a| matches!(a, DeferredAction::ConfirmPrompt)));
449    }
450
451    #[test]
452    fn test_prompt_escape_cancels() {
453        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
454        let mut ctx = InputContext::new();
455
456        prompt.handle_key_event(&key(KeyCode::Esc), &mut ctx);
457        assert!(ctx
458            .deferred_actions
459            .iter()
460            .any(|a| matches!(a, DeferredAction::ClosePrompt)));
461    }
462
463    #[test]
464    fn test_prompt_is_modal() {
465        let prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
466        assert!(prompt.is_modal());
467    }
468
469    #[test]
470    fn test_prompt_ctrl_p_returns_ignored() {
471        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
472        let mut ctx = InputContext::new();
473
474        // Ctrl+P should return Ignored so it can be handled by global keybindings
475        let result = prompt.handle_key_event(&key_with_ctrl('p'), &mut ctx);
476        assert_eq!(result, InputResult::Ignored, "Ctrl+P should return Ignored");
477    }
478
479    #[test]
480    fn test_prompt_ctrl_p_dispatch_returns_ignored() {
481        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
482        let mut ctx = InputContext::new();
483
484        // dispatch_input should also return Ignored for Ctrl+P (not Consumed by modal behavior)
485        let result = prompt.dispatch_input(&key_with_ctrl('p'), &mut ctx);
486        assert_eq!(
487            result,
488            InputResult::Ignored,
489            "dispatch_input should return Ignored for Ctrl+P"
490        );
491    }
492}