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