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