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                if let Some(selected) = self.selected_suggestion {
241                    if let Some(suggestion) = self.suggestions.get(selected) {
242                        if !suggestion.disabled {
243                            let value = suggestion.get_value().to_string();
244                            // For QuickOpen mode, preserve the prefix character
245                            if matches!(
246                                self.prompt_type,
247                                crate::view::prompt::PromptType::QuickOpen
248                            ) {
249                                let prefix = self
250                                    .input
251                                    .chars()
252                                    .next()
253                                    .filter(|c| *c == '>' || *c == '#' || *c == ':');
254                                if let Some(p) = prefix {
255                                    self.input = format!("{}{}", p, value);
256                                } else {
257                                    self.input = value;
258                                }
259                            } else {
260                                self.input = value;
261                            }
262                            self.cursor_pos = self.input.len();
263                            self.clear_selection();
264                        }
265                    }
266                }
267                ctx.defer(DeferredAction::UpdatePromptSuggestions);
268                InputResult::Consumed
269            }
270
271            _ => InputResult::Consumed, // Modal - consume all unhandled keys
272        }
273    }
274
275    fn is_modal(&self) -> bool {
276        true
277    }
278}
279
280impl Prompt {
281    fn handle_ctrl_key(&mut self, c: char, ctx: &mut InputContext) -> InputResult {
282        match c {
283            'a' => {
284                // Select all
285                self.selection_anchor = Some(0);
286                self.cursor_pos = self.input.len();
287                InputResult::Consumed
288            }
289            'c' => {
290                // Copy - defer to Editor for clipboard access
291                ctx.defer(DeferredAction::ExecuteAction(
292                    crate::input::keybindings::Action::PromptCopy,
293                ));
294                InputResult::Consumed
295            }
296            'x' => {
297                // Cut - defer to Editor for clipboard access
298                ctx.defer(DeferredAction::ExecuteAction(
299                    crate::input::keybindings::Action::PromptCut,
300                ));
301                InputResult::Consumed
302            }
303            'v' => {
304                // Paste - defer to Editor for clipboard access
305                ctx.defer(DeferredAction::ExecuteAction(
306                    crate::input::keybindings::Action::PromptPaste,
307                ));
308                InputResult::Consumed
309            }
310            'k' => {
311                // Delete to end of line
312                self.delete_to_end();
313                ctx.defer(DeferredAction::UpdatePromptSuggestions);
314                InputResult::Consumed
315            }
316            // Pass through other Ctrl+key combinations to global keybindings (e.g., Ctrl+P to toggle Quick Open)
317            _ => InputResult::Ignored,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::view::prompt::PromptType;
326
327    fn key(code: KeyCode) -> KeyEvent {
328        KeyEvent::new(code, KeyModifiers::NONE)
329    }
330
331    fn key_with_ctrl(c: char) -> KeyEvent {
332        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
333    }
334
335    fn key_with_shift(code: KeyCode) -> KeyEvent {
336        KeyEvent::new(code, KeyModifiers::SHIFT)
337    }
338
339    #[test]
340    fn test_prompt_character_input() {
341        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
342        let mut ctx = InputContext::new();
343
344        prompt.handle_key_event(
345            &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
346            &mut ctx,
347        );
348        prompt.handle_key_event(
349            &KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
350            &mut ctx,
351        );
352
353        assert_eq!(prompt.input, "hi");
354        assert_eq!(prompt.cursor_pos, 2);
355    }
356
357    #[test]
358    fn test_prompt_backspace() {
359        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
360        prompt.input = "hello".to_string();
361        prompt.cursor_pos = 5;
362        let mut ctx = InputContext::new();
363
364        prompt.handle_key_event(&key(KeyCode::Backspace), &mut ctx);
365        assert_eq!(prompt.input, "hell");
366        assert_eq!(prompt.cursor_pos, 4);
367    }
368
369    #[test]
370    fn test_prompt_cursor_movement() {
371        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
372        prompt.input = "hello".to_string();
373        prompt.cursor_pos = 5;
374        let mut ctx = InputContext::new();
375
376        // Move to start
377        prompt.handle_key_event(&key(KeyCode::Home), &mut ctx);
378        assert_eq!(prompt.cursor_pos, 0);
379
380        // Move to end
381        prompt.handle_key_event(&key(KeyCode::End), &mut ctx);
382        assert_eq!(prompt.cursor_pos, 5);
383
384        // Move left
385        prompt.handle_key_event(&key(KeyCode::Left), &mut ctx);
386        assert_eq!(prompt.cursor_pos, 4);
387
388        // Move right
389        prompt.handle_key_event(&key(KeyCode::Right), &mut ctx);
390        assert_eq!(prompt.cursor_pos, 5);
391    }
392
393    #[test]
394    fn test_prompt_selection() {
395        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
396        prompt.input = "hello world".to_string();
397        prompt.cursor_pos = 0;
398        let mut ctx = InputContext::new();
399
400        // Select with Shift+Right
401        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
402        prompt.handle_key_event(&key_with_shift(KeyCode::Right), &mut ctx);
403        assert!(prompt.has_selection());
404        assert_eq!(prompt.selected_text(), Some("he".to_string()));
405
406        // Select all with Ctrl+A
407        prompt.handle_key_event(&key_with_ctrl('a'), &mut ctx);
408        assert_eq!(prompt.selected_text(), Some("hello world".to_string()));
409    }
410
411    #[test]
412    fn test_prompt_enter_confirms() {
413        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
414        let mut ctx = InputContext::new();
415
416        prompt.handle_key_event(&key(KeyCode::Enter), &mut ctx);
417        assert!(ctx
418            .deferred_actions
419            .iter()
420            .any(|a| matches!(a, DeferredAction::ConfirmPrompt)));
421    }
422
423    #[test]
424    fn test_prompt_escape_cancels() {
425        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
426        let mut ctx = InputContext::new();
427
428        prompt.handle_key_event(&key(KeyCode::Esc), &mut ctx);
429        assert!(ctx
430            .deferred_actions
431            .iter()
432            .any(|a| matches!(a, DeferredAction::ClosePrompt)));
433    }
434
435    #[test]
436    fn test_prompt_is_modal() {
437        let prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
438        assert!(prompt.is_modal());
439    }
440
441    #[test]
442    fn test_prompt_ctrl_p_returns_ignored() {
443        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
444        let mut ctx = InputContext::new();
445
446        // Ctrl+P should return Ignored so it can be handled by global keybindings
447        let result = prompt.handle_key_event(&key_with_ctrl('p'), &mut ctx);
448        assert_eq!(result, InputResult::Ignored, "Ctrl+P should return Ignored");
449    }
450
451    #[test]
452    fn test_prompt_ctrl_p_dispatch_returns_ignored() {
453        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
454        let mut ctx = InputContext::new();
455
456        // dispatch_input should also return Ignored for Ctrl+P (not Consumed by modal behavior)
457        let result = prompt.dispatch_input(&key_with_ctrl('p'), &mut ctx);
458        assert_eq!(
459            result,
460            InputResult::Ignored,
461            "dispatch_input should return Ignored for Ctrl+P"
462        );
463    }
464}