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                // Keyboard navigation re-engages keep-selection-visible
137                // scrolling after a mouse-wheel scroll of the list (#2119).
138                self.manual_scroll = false;
139                if !self.suggestions.is_empty() {
140                    // Don't wrap around - stay at 0 if already at the beginning
141                    if let Some(selected) = self.selected_suggestion {
142                        let new_selected = if selected == 0 { 0 } else { selected - 1 };
143                        self.selected_suggestion = Some(new_selected);
144                        // For non-plugin prompts (except QuickOpen), or plugin prompts
145                        // with sync_input_on_navigate, update input to match selected suggestion
146                        let should_sync = self.sync_input_on_navigate
147                            || !matches!(
148                                self.prompt_type,
149                                crate::view::prompt::PromptType::Plugin { .. }
150                                    | crate::view::prompt::PromptType::QuickOpen
151                                    | crate::view::prompt::PromptType::LiveGrep
152                            );
153                        if should_sync {
154                            if let Some(suggestion) = self.suggestions.get(new_selected) {
155                                self.input = suggestion.get_value().to_string();
156                                self.cursor_pos = self.input.len();
157                                self.selection_anchor = Some(0);
158                            }
159                        }
160                        // For theme selection, trigger live preview
161                        if matches!(
162                            self.prompt_type,
163                            crate::view::prompt::PromptType::SelectTheme { .. }
164                        ) {
165                            ctx.defer(DeferredAction::PreviewThemeFromPrompt);
166                        }
167                        // For plugin prompts, notify about selection change (for live preview)
168                        if matches!(
169                            self.prompt_type,
170                            crate::view::prompt::PromptType::Plugin { .. }
171                        ) {
172                            ctx.defer(DeferredAction::PromptSelectionChanged {
173                                selected_index: new_selected,
174                            });
175                        }
176                    }
177                } else {
178                    // No suggestions - use history
179                    ctx.defer(DeferredAction::PromptHistoryPrev);
180                }
181                InputResult::Consumed
182            }
183            KeyCode::Down => {
184                self.manual_scroll = false;
185                if !self.suggestions.is_empty() {
186                    // Don't wrap around - stay at end if already at the last item
187                    if let Some(selected) = self.selected_suggestion {
188                        let new_selected = (selected + 1).min(self.suggestions.len() - 1);
189                        self.selected_suggestion = Some(new_selected);
190                        // For non-plugin prompts (except QuickOpen), or plugin prompts
191                        // with sync_input_on_navigate, update input to match selected suggestion
192                        let should_sync = self.sync_input_on_navigate
193                            || !matches!(
194                                self.prompt_type,
195                                crate::view::prompt::PromptType::Plugin { .. }
196                                    | crate::view::prompt::PromptType::QuickOpen
197                                    | crate::view::prompt::PromptType::LiveGrep
198                            );
199                        if should_sync {
200                            if let Some(suggestion) = self.suggestions.get(new_selected) {
201                                self.input = suggestion.get_value().to_string();
202                                self.cursor_pos = self.input.len();
203                                self.selection_anchor = Some(0);
204                            }
205                        }
206                        // For theme selection, trigger live preview
207                        if matches!(
208                            self.prompt_type,
209                            crate::view::prompt::PromptType::SelectTheme { .. }
210                        ) {
211                            ctx.defer(DeferredAction::PreviewThemeFromPrompt);
212                        }
213                        // For plugin prompts, notify about selection change (for live preview)
214                        if matches!(
215                            self.prompt_type,
216                            crate::view::prompt::PromptType::Plugin { .. }
217                        ) {
218                            ctx.defer(DeferredAction::PromptSelectionChanged {
219                                selected_index: new_selected,
220                            });
221                        }
222                    }
223                } else {
224                    // No suggestions - use history
225                    ctx.defer(DeferredAction::PromptHistoryNext);
226                }
227                InputResult::Consumed
228            }
229            KeyCode::PageUp => {
230                self.manual_scroll = false;
231                if let Some(selected) = self.selected_suggestion {
232                    self.selected_suggestion = Some(selected.saturating_sub(10));
233                }
234                InputResult::Consumed
235            }
236            KeyCode::PageDown => {
237                self.manual_scroll = false;
238                if let Some(selected) = self.selected_suggestion {
239                    let len = self.suggestions.len();
240                    let new_pos = selected + 10;
241                    self.selected_suggestion = Some(new_pos.min(len.saturating_sub(1)));
242                }
243                InputResult::Consumed
244            }
245
246            // Tab accepts suggestion
247            KeyCode::Tab => {
248                // In a floating-overlay prompt (Live Grep) the
249                // suggestion's `value` field is opaque (the Finder
250                // library uses indices like "0", "1", …) so accepting
251                // it would clobber the search query with garbage.
252                // Arrow keys already move the selection, so Tab is
253                // simply a no-op in overlay mode.
254                if self.overlay {
255                    return InputResult::Consumed;
256                }
257                if let Some(selected) = self.selected_suggestion {
258                    if let Some(suggestion) = self.suggestions.get(selected) {
259                        if !suggestion.disabled {
260                            let value = suggestion.get_value().to_string();
261                            // For QuickOpen mode, preserve the prefix character
262                            if matches!(
263                                self.prompt_type,
264                                crate::view::prompt::PromptType::QuickOpen
265                            ) {
266                                let prefix = self
267                                    .input
268                                    .chars()
269                                    .next()
270                                    .filter(|c| *c == '>' || *c == '#' || *c == ':');
271                                if let Some(p) = prefix {
272                                    self.input = format!("{}{}", p, value);
273                                } else {
274                                    self.input = value;
275                                }
276                            } else {
277                                self.input = value;
278                            }
279                            self.cursor_pos = self.input.len();
280                            self.clear_selection();
281                        }
282                    }
283                }
284                ctx.defer(DeferredAction::UpdatePromptSuggestions);
285                InputResult::Consumed
286            }
287
288            _ => InputResult::Consumed, // Modal - consume all unhandled keys
289        }
290    }
291
292    fn is_modal(&self) -> bool {
293        true
294    }
295}
296
297impl Prompt {
298    fn handle_ctrl_key(&mut self, c: char, ctx: &mut InputContext) -> InputResult {
299        match c {
300            'a' => {
301                // Select all
302                self.selection_anchor = Some(0);
303                self.cursor_pos = self.input.len();
304                InputResult::Consumed
305            }
306            'c' => {
307                // Copy - defer to Editor for clipboard access
308                ctx.defer(DeferredAction::ExecuteAction(
309                    crate::input::keybindings::Action::PromptCopy,
310                ));
311                InputResult::Consumed
312            }
313            'x' => {
314                // Cut - defer to Editor for clipboard access
315                ctx.defer(DeferredAction::ExecuteAction(
316                    crate::input::keybindings::Action::PromptCut,
317                ));
318                InputResult::Consumed
319            }
320            'v' => {
321                // Paste - defer to Editor for clipboard access
322                ctx.defer(DeferredAction::ExecuteAction(
323                    crate::input::keybindings::Action::PromptPaste,
324                ));
325                InputResult::Consumed
326            }
327            'k' => {
328                // Delete to end of line
329                self.delete_to_end();
330                ctx.defer(DeferredAction::UpdatePromptSuggestions);
331                InputResult::Consumed
332            }
333            'u' => {
334                // Delete to start of line (standard readline kill).
335                // Without this, Ctrl+U did nothing and the only way to
336                // clear the command-palette line was repeated Backspace
337                // (issue #2143).
338                self.clear_selection();
339                self.delete_to_start();
340                ctx.defer(DeferredAction::UpdatePromptSuggestions);
341                InputResult::Consumed
342            }
343            'z' => {
344                // Undo the last input edit. Operates on the prompt's own
345                // history so undo edits the query box, not the underlying
346                // (modal-inaccessible) buffer. Consumed unconditionally so
347                // it never falls through to the global buffer undo.
348                if self.undo_input() {
349                    ctx.defer(DeferredAction::UpdatePromptSuggestions);
350                }
351                InputResult::Consumed
352            }
353            'y' => {
354                // Redo the last undone input edit.
355                if self.redo_input() {
356                    ctx.defer(DeferredAction::UpdatePromptSuggestions);
357                }
358                InputResult::Consumed
359            }
360            // Pass through other Ctrl+key combinations to global keybindings (e.g., Ctrl+P to toggle Quick Open)
361            _ => InputResult::Ignored,
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::view::prompt::PromptType;
370
371    fn key(code: KeyCode) -> KeyEvent {
372        KeyEvent::new(code, KeyModifiers::NONE)
373    }
374
375    fn key_with_ctrl(c: char) -> KeyEvent {
376        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
377    }
378
379    fn key_with_shift(code: KeyCode) -> KeyEvent {
380        KeyEvent::new(code, KeyModifiers::SHIFT)
381    }
382
383    #[test]
384    fn test_prompt_character_input() {
385        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
386        let mut ctx = InputContext::new();
387
388        prompt.handle_key_event(
389            &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
390            &mut ctx,
391        );
392        prompt.handle_key_event(
393            &KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
394            &mut ctx,
395        );
396
397        assert_eq!(prompt.input, "hi");
398        assert_eq!(prompt.cursor_pos, 2);
399    }
400
401    #[test]
402    fn test_prompt_backspace() {
403        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
404        prompt.input = "hello".to_string();
405        prompt.cursor_pos = 5;
406        let mut ctx = InputContext::new();
407
408        prompt.handle_key_event(&key(KeyCode::Backspace), &mut ctx);
409        assert_eq!(prompt.input, "hell");
410        assert_eq!(prompt.cursor_pos, 4);
411    }
412
413    #[test]
414    fn test_prompt_cursor_movement() {
415        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
416        prompt.input = "hello".to_string();
417        prompt.cursor_pos = 5;
418        let mut ctx = InputContext::new();
419
420        // Move to start
421        prompt.handle_key_event(&key(KeyCode::Home), &mut ctx);
422        assert_eq!(prompt.cursor_pos, 0);
423
424        // Move to end
425        prompt.handle_key_event(&key(KeyCode::End), &mut ctx);
426        assert_eq!(prompt.cursor_pos, 5);
427
428        // Move left
429        prompt.handle_key_event(&key(KeyCode::Left), &mut ctx);
430        assert_eq!(prompt.cursor_pos, 4);
431
432        // Move right
433        prompt.handle_key_event(&key(KeyCode::Right), &mut ctx);
434        assert_eq!(prompt.cursor_pos, 5);
435    }
436
437    #[test]
438    fn test_prompt_selection() {
439        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
440        prompt.input = "hello world".to_string();
441        prompt.cursor_pos = 0;
442        let mut ctx = InputContext::new();
443
444        // Select with Shift+Right
445        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
446        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
447        assert!(prompt.has_selection());
448        assert_eq!(prompt.selected_text(), Some("he".to_string()));
449
450        // Select all with Ctrl+A
451        prompt.handle_key_event(&key_with_ctrl('a'), &mut ctx);
452        assert_eq!(prompt.selected_text(), Some("hello world".to_string()));
453    }
454
455    #[test]
456    fn test_prompt_enter_confirms() {
457        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
458        let mut ctx = InputContext::new();
459
460        prompt.handle_key_event(&key(KeyCode::Enter), &mut ctx);
461        assert!(ctx
462            .deferred_actions
463            .iter()
464            .any(|a| matches!(a, DeferredAction::ConfirmPrompt)));
465    }
466
467    #[test]
468    fn test_prompt_escape_cancels() {
469        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
470        let mut ctx = InputContext::new();
471
472        prompt.handle_key_event(&key(KeyCode::Esc), &mut ctx);
473        assert!(ctx
474            .deferred_actions
475            .iter()
476            .any(|a| matches!(a, DeferredAction::ClosePrompt)));
477    }
478
479    #[test]
480    fn test_prompt_is_modal() {
481        let prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
482        assert!(prompt.is_modal());
483    }
484
485    #[test]
486    fn test_prompt_ctrl_p_returns_ignored() {
487        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
488        let mut ctx = InputContext::new();
489
490        // Ctrl+P should return Ignored so it can be handled by global keybindings
491        let result = prompt.handle_key_event(&key_with_ctrl('p'), &mut ctx);
492        assert_eq!(result, InputResult::Ignored, "Ctrl+P should return Ignored");
493    }
494
495    #[test]
496    fn test_prompt_ctrl_p_dispatch_returns_ignored() {
497        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
498        let mut ctx = InputContext::new();
499
500        // dispatch_input should also return Ignored for Ctrl+P (not Consumed by modal behavior)
501        let result = prompt.dispatch_input(&key_with_ctrl('p'), &mut ctx);
502        assert_eq!(
503            result,
504            InputResult::Ignored,
505            "dispatch_input should return Ignored for Ctrl+P"
506        );
507    }
508}