Skip to main content

fresh/view/
prompt.rs

1//! Prompt/minibuffer system for user input
2
3use crate::input::commands::Suggestion;
4use crate::primitives::grapheme;
5use crate::primitives::word_navigation::{
6    find_word_end_bytes, find_word_start_bytes, is_word_char,
7};
8
9/// Type of prompt - determines what action to take when user confirms
10#[derive(Debug, Clone, PartialEq)]
11pub enum PromptType {
12    /// Open a file
13    OpenFile,
14    /// Open a file with a specific encoding (used when detect_encoding is disabled)
15    /// Contains the path to open after encoding selection
16    OpenFileWithEncoding { path: std::path::PathBuf },
17    /// Reload current file with a different encoding
18    /// Requires the buffer to have no unsaved modifications
19    ReloadWithEncoding,
20    /// Switch to a different project folder (change working directory)
21    SwitchProject,
22    /// Save current buffer to a new file
23    SaveFileAs,
24    /// Search for text in buffer
25    Search,
26    /// Search for text in buffer (for replace operation - will prompt for replacement after)
27    ReplaceSearch,
28    /// Replace text in buffer
29    Replace { search: String },
30    /// Search for text in buffer (for query-replace - will prompt for replacement after)
31    QueryReplaceSearch,
32    /// Query replace text in buffer - prompt for replacement text
33    QueryReplace { search: String },
34    /// Query replace confirmation prompt (y/n/!/q for each match)
35    QueryReplaceConfirm,
36    /// Execute a command by name (M-x)
37    Command,
38    /// Quick Open - unified prompt with prefix-based provider routing
39    /// Supports file finding (default), commands (>), buffers (#), goto line (:)
40    QuickOpen,
41    /// Go to a specific line number
42    GotoLine,
43    /// Go to a specific byte offset (large file without line index scan)
44    GotoByteOffset,
45    /// Confirm whether to scan a large file for exact line numbers before Go To Line
46    GotoLineScanConfirm,
47    /// Choose an ANSI background file
48    SetBackgroundFile,
49    /// Set background blend ratio (0-1)
50    SetBackgroundBlend,
51    /// Plugin-controlled prompt with custom type identifier
52    /// The string identifier is used to filter hooks in plugin code
53    Plugin { custom_type: String },
54    /// LSP Rename operation
55    /// Stores the original text, start/end positions in buffer, and overlay handle
56    LspRename {
57        original_text: String,
58        start_pos: usize,
59        end_pos: usize,
60        overlay_handle: crate::view::overlay::OverlayHandle,
61    },
62    /// Record a macro - prompts for register (0-9)
63    RecordMacro,
64    /// Play a macro - prompts for register (0-9)
65    PlayMacro,
66    /// Set a bookmark - prompts for register (0-9)
67    SetBookmark,
68    /// Jump to a bookmark - prompts for register (0-9)
69    JumpToBookmark,
70    /// Set page width (empty clears to viewport)
71    SetPageWidth,
72    /// Add a vertical ruler at a column position
73    AddRuler,
74    /// Remove a vertical ruler (select from list)
75    RemoveRuler,
76    /// Set tab size for current buffer
77    SetTabSize,
78    /// Set line ending format for current buffer
79    SetLineEnding,
80    /// Set text encoding format for current buffer
81    SetEncoding,
82    /// Set language/syntax highlighting for current buffer
83    SetLanguage,
84    /// Stop a running LSP server (select from list)
85    StopLspServer,
86    /// Restart LSP server(s) (select from list)
87    RestartLspServer,
88    /// Select a theme (select from list)
89    /// Stores the original theme name for restoration on cancel
90    SelectTheme { original_theme: String },
91    /// Select a keybinding map (select from list)
92    SelectKeybindingMap,
93    /// Select a cursor style (select from list)
94    SelectCursorStyle,
95    /// Select a UI locale/language (select from list)
96    SelectLocale,
97    /// Select a theme for copy with formatting
98    CopyWithFormattingTheme,
99    /// Confirm reverting a modified file
100    ConfirmRevert,
101    /// Confirm saving over a file that changed on disk
102    ConfirmSaveConflict,
103    /// Confirm saving with sudo after permission denied
104    ConfirmSudoSave {
105        info: crate::model::buffer::SudoSaveRequired,
106    },
107    /// Confirm overwriting an existing file during SaveAs
108    ConfirmOverwriteFile { path: std::path::PathBuf },
109    /// Confirm closing a modified buffer (save/discard/cancel)
110    /// Stores buffer_id to close after user confirms
111    ConfirmCloseBuffer {
112        buffer_id: crate::model::event::BufferId,
113    },
114    /// Confirm quitting with modified buffers
115    ConfirmQuitWithModified,
116    /// File Explorer rename operation
117    /// Stores the original path and name for the file/directory being renamed
118    FileExplorerRename {
119        original_path: std::path::PathBuf,
120        original_name: String,
121        /// True if this rename is for a newly created file (should switch focus to editor after)
122        /// False if renaming an existing file (should keep focus in file explorer)
123        is_new_file: bool,
124    },
125    /// Confirm deleting a file or directory in the file explorer
126    ConfirmDeleteFile {
127        path: std::path::PathBuf,
128        is_dir: bool,
129    },
130    /// Confirm loading a large file with non-resynchronizable encoding
131    /// (like GB18030, GBK, Shift-JIS, EUC-KR) that requires full file loading
132    ConfirmLargeFileEncoding { path: std::path::PathBuf },
133    /// Switch to a tab by name (from the current split's open buffers)
134    SwitchToTab,
135    /// Run shell command on buffer/selection
136    /// If replace is true, replace the input with the output
137    /// If replace is false, output goes to a new buffer
138    ShellCommand { replace: bool },
139    /// Async prompt from plugin (for editor.prompt() API)
140    /// The result is returned via callback resolution
141    AsyncPrompt,
142}
143
144/// Prompt state for the minibuffer
145#[derive(Debug, Clone)]
146pub struct Prompt {
147    /// The prompt message (e.g., "Find file: ")
148    pub message: String,
149    /// User's current input
150    pub input: String,
151    /// Cursor position in the input
152    pub cursor_pos: usize,
153    /// What to do when user confirms
154    pub prompt_type: PromptType,
155    /// Autocomplete suggestions (filtered)
156    pub suggestions: Vec<Suggestion>,
157    /// Original unfiltered suggestions (for prompts that filter client-side like SwitchToTab)
158    pub original_suggestions: Option<Vec<Suggestion>>,
159    /// Currently selected suggestion index
160    pub selected_suggestion: Option<usize>,
161    /// Selection anchor position (for Shift+Arrow selection)
162    /// When Some(pos), there's a selection from anchor to cursor_pos
163    pub selection_anchor: Option<usize>,
164    /// Tracks the input value when suggestions were last set by a plugin.
165    /// Used to skip Rust-side filtering when plugin has already filtered for this input.
166    pub suggestions_set_for_input: Option<String>,
167    /// When true, navigating suggestions updates the input text (selected) to match.
168    /// Used by plugin prompts that want picker-like behavior (e.g. compose width).
169    pub sync_input_on_navigate: bool,
170}
171
172impl Prompt {
173    /// Create a new prompt
174    pub fn new(message: String, prompt_type: PromptType) -> Self {
175        Self {
176            message,
177            input: String::new(),
178            cursor_pos: 0,
179            prompt_type,
180            suggestions: Vec::new(),
181            original_suggestions: None,
182            selected_suggestion: None,
183            selection_anchor: None,
184            suggestions_set_for_input: None,
185            sync_input_on_navigate: false,
186        }
187    }
188
189    /// Create a new prompt with suggestions
190    ///
191    /// The suggestions are stored both as the current filtered list and as the original
192    /// unfiltered list (for prompts that filter client-side like SwitchToTab).
193    pub fn with_suggestions(
194        message: String,
195        prompt_type: PromptType,
196        suggestions: Vec<Suggestion>,
197    ) -> Self {
198        let selected_suggestion = if suggestions.is_empty() {
199            None
200        } else {
201            Some(0)
202        };
203        Self {
204            message,
205            input: String::new(),
206            cursor_pos: 0,
207            prompt_type,
208            original_suggestions: Some(suggestions.clone()),
209            suggestions,
210            selected_suggestion,
211            selection_anchor: None,
212            suggestions_set_for_input: None,
213            sync_input_on_navigate: false,
214        }
215    }
216
217    /// Create a new prompt with initial text (selected so typing replaces it)
218    pub fn with_initial_text(
219        message: String,
220        prompt_type: PromptType,
221        initial_text: String,
222    ) -> Self {
223        let cursor_pos = initial_text.len();
224        // Select all initial text so typing immediately replaces it
225        let selection_anchor = if initial_text.is_empty() {
226            None
227        } else {
228            Some(0)
229        };
230        Self {
231            message,
232            input: initial_text,
233            cursor_pos,
234            prompt_type,
235            suggestions: Vec::new(),
236            original_suggestions: None,
237            selected_suggestion: None,
238            selection_anchor,
239            suggestions_set_for_input: None,
240            sync_input_on_navigate: false,
241        }
242    }
243
244    /// Move cursor left (to previous grapheme cluster boundary)
245    ///
246    /// Uses grapheme cluster boundaries for proper handling of combining characters
247    /// like Thai diacritics, emoji with modifiers, etc.
248    pub fn cursor_left(&mut self) {
249        if self.cursor_pos > 0 {
250            self.cursor_pos = grapheme::prev_grapheme_boundary(&self.input, self.cursor_pos);
251        }
252    }
253
254    /// Move cursor right (to next grapheme cluster boundary)
255    ///
256    /// Uses grapheme cluster boundaries for proper handling of combining characters
257    /// like Thai diacritics, emoji with modifiers, etc.
258    pub fn cursor_right(&mut self) {
259        if self.cursor_pos < self.input.len() {
260            self.cursor_pos = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
261        }
262    }
263
264    /// Insert a character at the cursor position
265    pub fn insert_char(&mut self, ch: char) {
266        self.input.insert(self.cursor_pos, ch);
267        self.cursor_pos += ch.len_utf8();
268    }
269
270    /// Delete one code point before cursor (backspace)
271    ///
272    /// Deletes one Unicode code point at a time, allowing layer-by-layer deletion
273    /// of combining characters. For Thai text, this means you can delete just the
274    /// tone mark without removing the base consonant.
275    pub fn backspace(&mut self) {
276        if self.cursor_pos > 0 {
277            // Find the previous character (code point) boundary, not grapheme boundary
278            // This allows layer-by-layer deletion of combining marks
279            let prev_boundary = self.input[..self.cursor_pos]
280                .char_indices()
281                .next_back()
282                .map(|(i, _)| i)
283                .unwrap_or(0);
284            self.input.drain(prev_boundary..self.cursor_pos);
285            self.cursor_pos = prev_boundary;
286        }
287    }
288
289    /// Delete grapheme cluster at cursor (delete key)
290    ///
291    /// Deletes the entire grapheme cluster, handling combining characters properly.
292    pub fn delete(&mut self) {
293        if self.cursor_pos < self.input.len() {
294            let next_boundary = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
295            self.input.drain(self.cursor_pos..next_boundary);
296        }
297    }
298
299    /// Move to start of input
300    pub fn move_to_start(&mut self) {
301        self.cursor_pos = 0;
302    }
303
304    /// Move to end of input
305    pub fn move_to_end(&mut self) {
306        self.cursor_pos = self.input.len();
307    }
308
309    /// Set the input text and cursor position
310    ///
311    /// Used for history navigation - replaces the entire input with a new value
312    /// and moves cursor to the end.
313    ///
314    /// # Example
315    /// ```
316    /// # use fresh::prompt::{Prompt, PromptType};
317    /// let mut prompt = Prompt::new("Search: ".to_string(), PromptType::Search);
318    /// prompt.input = "current".to_string();
319    /// prompt.cursor_pos = 7;
320    ///
321    /// prompt.set_input("from history".to_string());
322    /// assert_eq!(prompt.input, "from history");
323    /// assert_eq!(prompt.cursor_pos, 12); // At end
324    /// ```
325    pub fn set_input(&mut self, text: String) {
326        self.cursor_pos = text.len();
327        self.input = text;
328        self.clear_selection();
329    }
330
331    /// Select next suggestion
332    pub fn select_next_suggestion(&mut self) {
333        if !self.suggestions.is_empty() {
334            self.selected_suggestion = Some(match self.selected_suggestion {
335                Some(idx) if idx + 1 < self.suggestions.len() => idx + 1,
336                Some(_) => 0, // Wrap to start
337                None => 0,
338            });
339        }
340    }
341
342    /// Select previous suggestion
343    pub fn select_prev_suggestion(&mut self) {
344        if !self.suggestions.is_empty() {
345            self.selected_suggestion = Some(match self.selected_suggestion {
346                Some(0) => self.suggestions.len() - 1, // Wrap to end
347                Some(idx) => idx - 1,
348                None => 0,
349            });
350        }
351    }
352
353    /// Get the currently selected suggestion value
354    pub fn selected_value(&self) -> Option<String> {
355        self.selected_suggestion
356            .and_then(|idx| self.suggestions.get(idx))
357            .map(|s| s.get_value().to_string())
358    }
359
360    /// Get the final input (use selected suggestion if available, otherwise raw input)
361    pub fn get_final_input(&self) -> String {
362        self.selected_value().unwrap_or_else(|| self.input.clone())
363    }
364
365    /// Apply fuzzy filtering to suggestions based on current input
366    ///
367    /// If `match_description` is true, also matches against suggestion descriptions.
368    /// Updates `suggestions` with filtered and sorted results.
369    pub fn filter_suggestions(&mut self, match_description: bool) {
370        use crate::input::fuzzy::{fuzzy_match, FuzzyMatch};
371
372        // Skip filtering if the plugin has already set suggestions for this exact input.
373        // This handles the race condition where run_hook("prompt_changed") is async:
374        // the plugin may have already responded with filtered results via setPromptSuggestions.
375        if let Some(ref set_for_input) = self.suggestions_set_for_input {
376            if set_for_input == &self.input {
377                return;
378            }
379        }
380
381        let Some(original) = &self.original_suggestions else {
382            return;
383        };
384
385        let input = &self.input;
386        let mut filtered: Vec<(crate::input::commands::Suggestion, i32)> = original
387            .iter()
388            .filter_map(|s| {
389                let text_result = fuzzy_match(input, &s.text);
390                let desc_result = if match_description {
391                    s.description
392                        .as_ref()
393                        .map(|d| fuzzy_match(input, d))
394                        .unwrap_or_else(FuzzyMatch::no_match)
395                } else {
396                    FuzzyMatch::no_match()
397                };
398                if text_result.matched || desc_result.matched {
399                    Some((s.clone(), text_result.score.max(desc_result.score)))
400                } else {
401                    None
402                }
403            })
404            .collect();
405
406        filtered.sort_by(|a, b| b.1.cmp(&a.1));
407        self.suggestions = filtered.into_iter().map(|(s, _)| s).collect();
408        self.selected_suggestion = if self.suggestions.is_empty() {
409            None
410        } else {
411            Some(0)
412        };
413    }
414
415    // ========================================================================
416    // Advanced editing operations (word-based, clipboard)
417    // ========================================================================
418    //
419    // MOTIVATION:
420    // These methods provide advanced editing capabilities in prompts that
421    // users expect from normal text editing:
422    // - Word-based deletion (Ctrl+Backspace/Delete)
423    // - Copy/paste/cut operations
424    //
425    // This enables consistent editing experience across both buffer editing
426    // and prompt input (command palette, file picker, search, etc.).
427
428    /// Delete from cursor to end of word (Ctrl+Delete).
429    ///
430    /// Deletes from the current cursor position to the end of the current word.
431    /// If the cursor is at a non-word character, skips to the next word and
432    /// deletes to its end.
433    ///
434    /// # Example
435    /// ```
436    /// # use fresh::prompt::{Prompt, PromptType};
437    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
438    /// prompt.input = "hello world".to_string();
439    /// prompt.cursor_pos = 0; // At start of "hello"
440    /// prompt.delete_word_forward();
441    /// assert_eq!(prompt.input, " world");
442    /// assert_eq!(prompt.cursor_pos, 0);
443    /// ```
444    pub fn delete_word_forward(&mut self) {
445        let word_end = find_word_end_bytes(self.input.as_bytes(), self.cursor_pos);
446        if word_end > self.cursor_pos {
447            self.input.drain(self.cursor_pos..word_end);
448            // Cursor stays at same position
449        }
450    }
451
452    /// Delete from start of word to cursor (Ctrl+Backspace).
453    ///
454    /// Deletes from the start of the current word to the cursor position.
455    /// If the cursor is after a non-word character, deletes the previous word.
456    ///
457    /// # Example
458    /// ```
459    /// # use fresh::prompt::{Prompt, PromptType};
460    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
461    /// prompt.input = "hello world".to_string();
462    /// prompt.cursor_pos = 5; // After "hello"
463    /// prompt.delete_word_backward();
464    /// assert_eq!(prompt.input, " world");
465    /// assert_eq!(prompt.cursor_pos, 0);
466    /// ```
467    pub fn delete_word_backward(&mut self) {
468        let word_start = find_word_start_bytes(self.input.as_bytes(), self.cursor_pos);
469        if word_start < self.cursor_pos {
470            self.input.drain(word_start..self.cursor_pos);
471            self.cursor_pos = word_start;
472        }
473    }
474
475    /// Delete from cursor to end of line (Ctrl+K).
476    ///
477    /// Deletes all text from the cursor position to the end of the input.
478    ///
479    /// # Example
480    /// ```
481    /// # use fresh::prompt::{Prompt, PromptType};
482    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
483    /// prompt.input = "hello world".to_string();
484    /// prompt.cursor_pos = 5; // After "hello"
485    /// prompt.delete_to_end();
486    /// assert_eq!(prompt.input, "hello");
487    /// assert_eq!(prompt.cursor_pos, 5);
488    /// ```
489    pub fn delete_to_end(&mut self) {
490        if self.cursor_pos < self.input.len() {
491            self.input.truncate(self.cursor_pos);
492        }
493    }
494
495    /// Get the current input text (for copy operation).
496    ///
497    /// Returns a copy of the entire input. In future, this could be extended
498    /// to support selection ranges for copying only selected text.
499    ///
500    /// # Example
501    /// ```
502    /// # use fresh::prompt::{Prompt, PromptType};
503    /// let mut prompt = Prompt::new("Search: ".to_string(), PromptType::Search);
504    /// prompt.input = "test query".to_string();
505    /// assert_eq!(prompt.get_text(), "test query");
506    /// ```
507    pub fn get_text(&self) -> String {
508        self.input.clone()
509    }
510
511    /// Clear the input (used for cut operation).
512    ///
513    /// Removes all text from the input and resets cursor to start.
514    ///
515    /// # Example
516    /// ```
517    /// # use fresh::prompt::{Prompt, PromptType};
518    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
519    /// prompt.input = "some text".to_string();
520    /// prompt.cursor_pos = 9;
521    /// prompt.clear();
522    /// assert_eq!(prompt.input, "");
523    /// assert_eq!(prompt.cursor_pos, 0);
524    /// ```
525    pub fn clear(&mut self) {
526        self.input.clear();
527        self.cursor_pos = 0;
528        // Also clear selection when clearing input
529        self.selected_suggestion = None;
530    }
531
532    /// Insert text at cursor position (used for paste operation).
533    ///
534    /// Inserts the given text at the current cursor position and moves
535    /// the cursor to the end of the inserted text.
536    ///
537    /// # Example
538    /// ```
539    /// # use fresh::prompt::{Prompt, PromptType};
540    /// let mut prompt = Prompt::new("Command: ".to_string(), PromptType::Command);
541    /// prompt.input = "save".to_string();
542    /// prompt.cursor_pos = 4;
543    /// prompt.insert_str(" file");
544    /// assert_eq!(prompt.input, "save file");
545    /// assert_eq!(prompt.cursor_pos, 9);
546    /// ```
547    pub fn insert_str(&mut self, text: &str) {
548        // If there's a selection, delete it first
549        if self.has_selection() {
550            self.delete_selection();
551        }
552        self.input.insert_str(self.cursor_pos, text);
553        self.cursor_pos += text.len();
554    }
555
556    // ========================================================================
557    // Selection support
558    // ========================================================================
559
560    /// Check if there's an active selection
561    pub fn has_selection(&self) -> bool {
562        self.selection_anchor.is_some() && self.selection_anchor != Some(self.cursor_pos)
563    }
564
565    /// Get the selection range (start, end) where start <= end
566    pub fn selection_range(&self) -> Option<(usize, usize)> {
567        if let Some(anchor) = self.selection_anchor {
568            if anchor != self.cursor_pos {
569                let start = anchor.min(self.cursor_pos);
570                let end = anchor.max(self.cursor_pos);
571                return Some((start, end));
572            }
573        }
574        None
575    }
576
577    /// Get the selected text
578    pub fn selected_text(&self) -> Option<String> {
579        self.selection_range()
580            .map(|(start, end)| self.input[start..end].to_string())
581    }
582
583    /// Delete the current selection and return the deleted text
584    pub fn delete_selection(&mut self) -> Option<String> {
585        if let Some((start, end)) = self.selection_range() {
586            let deleted = self.input[start..end].to_string();
587            self.input.drain(start..end);
588            self.cursor_pos = start;
589            self.selection_anchor = None;
590            Some(deleted)
591        } else {
592            None
593        }
594    }
595
596    /// Clear selection without deleting text
597    pub fn clear_selection(&mut self) {
598        self.selection_anchor = None;
599    }
600
601    /// Move cursor left with selection (by grapheme cluster)
602    pub fn move_left_selecting(&mut self) {
603        // Set anchor if not already set
604        if self.selection_anchor.is_none() {
605            self.selection_anchor = Some(self.cursor_pos);
606        }
607
608        // Move cursor left by grapheme cluster
609        if self.cursor_pos > 0 {
610            self.cursor_pos = grapheme::prev_grapheme_boundary(&self.input, self.cursor_pos);
611        }
612    }
613
614    /// Move cursor right with selection (by grapheme cluster)
615    pub fn move_right_selecting(&mut self) {
616        // Set anchor if not already set
617        if self.selection_anchor.is_none() {
618            self.selection_anchor = Some(self.cursor_pos);
619        }
620
621        // Move cursor right by grapheme cluster
622        if self.cursor_pos < self.input.len() {
623            self.cursor_pos = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
624        }
625    }
626
627    /// Move to start of input with selection
628    pub fn move_home_selecting(&mut self) {
629        if self.selection_anchor.is_none() {
630            self.selection_anchor = Some(self.cursor_pos);
631        }
632        self.cursor_pos = 0;
633    }
634
635    /// Move to end of input with selection
636    pub fn move_end_selecting(&mut self) {
637        if self.selection_anchor.is_none() {
638            self.selection_anchor = Some(self.cursor_pos);
639        }
640        self.cursor_pos = self.input.len();
641    }
642
643    /// Move to start of previous word with selection
644    /// Mimics Buffer's find_word_start_left behavior
645    pub fn move_word_left_selecting(&mut self) {
646        if self.selection_anchor.is_none() {
647            self.selection_anchor = Some(self.cursor_pos);
648        }
649
650        let bytes = self.input.as_bytes();
651        if self.cursor_pos == 0 {
652            return;
653        }
654
655        let mut new_pos = self.cursor_pos.saturating_sub(1);
656
657        // Skip non-word characters (spaces) backwards
658        while new_pos > 0 && !is_word_char(bytes[new_pos]) {
659            new_pos = new_pos.saturating_sub(1);
660        }
661
662        // Find start of word
663        while new_pos > 0 && is_word_char(bytes[new_pos.saturating_sub(1)]) {
664            new_pos = new_pos.saturating_sub(1);
665        }
666
667        self.cursor_pos = new_pos;
668    }
669
670    /// Move to end of next word with selection
671    /// For selection, we want to select whole words, so move to word END, not word START
672    pub fn move_word_right_selecting(&mut self) {
673        if self.selection_anchor.is_none() {
674            self.selection_anchor = Some(self.cursor_pos);
675        }
676
677        // Use find_word_end_bytes which moves to the END of words
678        let bytes = self.input.as_bytes();
679        let mut new_pos = find_word_end_bytes(bytes, self.cursor_pos);
680
681        // If we didn't move (already at word end), move forward to next word end
682        if new_pos == self.cursor_pos && new_pos < bytes.len() {
683            new_pos = (new_pos + 1).min(bytes.len());
684            new_pos = find_word_end_bytes(bytes, new_pos);
685        }
686
687        self.cursor_pos = new_pos;
688    }
689
690    /// Move to start of previous word (without selection)
691    /// Mimics Buffer's find_word_start_left behavior
692    pub fn move_word_left(&mut self) {
693        self.clear_selection();
694
695        let bytes = self.input.as_bytes();
696        if self.cursor_pos == 0 {
697            return;
698        }
699
700        let mut new_pos = self.cursor_pos.saturating_sub(1);
701
702        // Skip non-word characters (spaces) backwards
703        while new_pos > 0 && !is_word_char(bytes[new_pos]) {
704            new_pos = new_pos.saturating_sub(1);
705        }
706
707        // Find start of word
708        while new_pos > 0 && is_word_char(bytes[new_pos.saturating_sub(1)]) {
709            new_pos = new_pos.saturating_sub(1);
710        }
711
712        self.cursor_pos = new_pos;
713    }
714
715    /// Move to start of next word (without selection)
716    /// Mimics Buffer's find_word_start_right behavior
717    pub fn move_word_right(&mut self) {
718        self.clear_selection();
719
720        let bytes = self.input.as_bytes();
721        if self.cursor_pos >= bytes.len() {
722            return;
723        }
724
725        let mut new_pos = self.cursor_pos;
726
727        // Skip current word
728        while new_pos < bytes.len() && is_word_char(bytes[new_pos]) {
729            new_pos += 1;
730        }
731
732        // Skip non-word characters (spaces)
733        while new_pos < bytes.len() && !is_word_char(bytes[new_pos]) {
734            new_pos += 1;
735        }
736
737        self.cursor_pos = new_pos;
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    #[test]
746    fn test_delete_word_forward_basic() {
747        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
748        prompt.input = "hello world test".to_string();
749        prompt.cursor_pos = 0;
750
751        prompt.delete_word_forward();
752        assert_eq!(prompt.input, " world test");
753        assert_eq!(prompt.cursor_pos, 0);
754    }
755
756    #[test]
757    fn test_delete_word_forward_middle() {
758        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
759        prompt.input = "hello world test".to_string();
760        prompt.cursor_pos = 3; // Middle of "hello"
761
762        prompt.delete_word_forward();
763        assert_eq!(prompt.input, "hel world test");
764        assert_eq!(prompt.cursor_pos, 3);
765    }
766
767    #[test]
768    fn test_delete_word_forward_at_space() {
769        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
770        prompt.input = "hello world".to_string();
771        prompt.cursor_pos = 5; // At space after "hello"
772
773        prompt.delete_word_forward();
774        assert_eq!(prompt.input, "hello");
775        assert_eq!(prompt.cursor_pos, 5);
776    }
777
778    #[test]
779    fn test_delete_word_backward_basic() {
780        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
781        prompt.input = "hello world test".to_string();
782        prompt.cursor_pos = 5; // After "hello"
783
784        prompt.delete_word_backward();
785        assert_eq!(prompt.input, " world test");
786        assert_eq!(prompt.cursor_pos, 0);
787    }
788
789    #[test]
790    fn test_delete_word_backward_middle() {
791        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
792        prompt.input = "hello world test".to_string();
793        prompt.cursor_pos = 8; // Middle of "world"
794
795        prompt.delete_word_backward();
796        assert_eq!(prompt.input, "hello rld test");
797        assert_eq!(prompt.cursor_pos, 6);
798    }
799
800    #[test]
801    fn test_delete_word_backward_at_end() {
802        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
803        prompt.input = "hello world".to_string();
804        prompt.cursor_pos = 11; // At end
805
806        prompt.delete_word_backward();
807        assert_eq!(prompt.input, "hello ");
808        assert_eq!(prompt.cursor_pos, 6);
809    }
810
811    #[test]
812    fn test_delete_word_with_special_chars() {
813        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
814        prompt.input = "save-file-as".to_string();
815        prompt.cursor_pos = 12; // At end
816
817        // Delete "as"
818        prompt.delete_word_backward();
819        assert_eq!(prompt.input, "save-file-");
820        assert_eq!(prompt.cursor_pos, 10);
821
822        // Delete "file"
823        prompt.delete_word_backward();
824        assert_eq!(prompt.input, "save-");
825        assert_eq!(prompt.cursor_pos, 5);
826    }
827
828    #[test]
829    fn test_get_text() {
830        let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
831        prompt.input = "test content".to_string();
832
833        assert_eq!(prompt.get_text(), "test content");
834    }
835
836    #[test]
837    fn test_clear() {
838        let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
839        prompt.input = "some text".to_string();
840        prompt.cursor_pos = 5;
841        prompt.selected_suggestion = Some(0);
842
843        prompt.clear();
844
845        assert_eq!(prompt.input, "");
846        assert_eq!(prompt.cursor_pos, 0);
847        assert_eq!(prompt.selected_suggestion, None);
848    }
849
850    #[test]
851    fn test_delete_forward_basic() {
852        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
853        prompt.input = "hello".to_string();
854        prompt.cursor_pos = 1; // After 'h'
855
856        // Simulate delete key (remove 'e')
857        prompt.input.drain(prompt.cursor_pos..prompt.cursor_pos + 1);
858
859        assert_eq!(prompt.input, "hllo");
860        assert_eq!(prompt.cursor_pos, 1);
861    }
862
863    #[test]
864    fn test_delete_at_end() {
865        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
866        prompt.input = "hello".to_string();
867        prompt.cursor_pos = 5; // At end
868
869        // Delete at end should do nothing
870        if prompt.cursor_pos < prompt.input.len() {
871            prompt.input.drain(prompt.cursor_pos..prompt.cursor_pos + 1);
872        }
873
874        assert_eq!(prompt.input, "hello");
875        assert_eq!(prompt.cursor_pos, 5);
876    }
877
878    #[test]
879    fn test_insert_str_at_start() {
880        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
881        prompt.input = "world".to_string();
882        prompt.cursor_pos = 0;
883
884        prompt.insert_str("hello ");
885        assert_eq!(prompt.input, "hello world");
886        assert_eq!(prompt.cursor_pos, 6);
887    }
888
889    #[test]
890    fn test_insert_str_at_middle() {
891        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
892        prompt.input = "helloworld".to_string();
893        prompt.cursor_pos = 5;
894
895        prompt.insert_str(" ");
896        assert_eq!(prompt.input, "hello world");
897        assert_eq!(prompt.cursor_pos, 6);
898    }
899
900    #[test]
901    fn test_insert_str_at_end() {
902        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
903        prompt.input = "hello".to_string();
904        prompt.cursor_pos = 5;
905
906        prompt.insert_str(" world");
907        assert_eq!(prompt.input, "hello world");
908        assert_eq!(prompt.cursor_pos, 11);
909    }
910
911    #[test]
912    fn test_delete_word_forward_empty() {
913        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
914        prompt.input = "".to_string();
915        prompt.cursor_pos = 0;
916
917        prompt.delete_word_forward();
918        assert_eq!(prompt.input, "");
919        assert_eq!(prompt.cursor_pos, 0);
920    }
921
922    #[test]
923    fn test_delete_word_backward_empty() {
924        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
925        prompt.input = "".to_string();
926        prompt.cursor_pos = 0;
927
928        prompt.delete_word_backward();
929        assert_eq!(prompt.input, "");
930        assert_eq!(prompt.cursor_pos, 0);
931    }
932
933    #[test]
934    fn test_delete_word_forward_only_spaces() {
935        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
936        prompt.input = "   ".to_string();
937        prompt.cursor_pos = 0;
938
939        prompt.delete_word_forward();
940        assert_eq!(prompt.input, "");
941        assert_eq!(prompt.cursor_pos, 0);
942    }
943
944    #[test]
945    fn test_multiple_word_deletions() {
946        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
947        prompt.input = "one two three four".to_string();
948        prompt.cursor_pos = 18;
949
950        prompt.delete_word_backward(); // Delete "four"
951        assert_eq!(prompt.input, "one two three ");
952
953        prompt.delete_word_backward(); // Delete "three"
954        assert_eq!(prompt.input, "one two ");
955
956        prompt.delete_word_backward(); // Delete "two"
957        assert_eq!(prompt.input, "one ");
958    }
959
960    // Tests for selection functionality
961    #[test]
962    fn test_selection_with_shift_arrows() {
963        let mut prompt = Prompt::new("Command: ".to_string(), PromptType::Command);
964        prompt.input = "hello world".to_string();
965        prompt.cursor_pos = 5; // After "hello"
966
967        // No selection initially
968        assert!(!prompt.has_selection());
969        assert_eq!(prompt.selected_text(), None);
970
971        // Move right selecting - should select " "
972        prompt.move_right_selecting();
973        assert!(prompt.has_selection());
974        assert_eq!(prompt.selection_range(), Some((5, 6)));
975        assert_eq!(prompt.selected_text(), Some(" ".to_string()));
976
977        // Move right selecting again - should select " w"
978        prompt.move_right_selecting();
979        assert_eq!(prompt.selection_range(), Some((5, 7)));
980        assert_eq!(prompt.selected_text(), Some(" w".to_string()));
981
982        // Move left selecting - should shrink to " "
983        prompt.move_left_selecting();
984        assert_eq!(prompt.selection_range(), Some((5, 6)));
985        assert_eq!(prompt.selected_text(), Some(" ".to_string()));
986    }
987
988    #[test]
989    fn test_selection_backward() {
990        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
991        prompt.input = "abcdef".to_string();
992        prompt.cursor_pos = 4; // After "abcd"
993
994        // Select backward
995        prompt.move_left_selecting();
996        prompt.move_left_selecting();
997        assert!(prompt.has_selection());
998        assert_eq!(prompt.selection_range(), Some((2, 4)));
999        assert_eq!(prompt.selected_text(), Some("cd".to_string()));
1000    }
1001
1002    #[test]
1003    fn test_selection_with_home_end() {
1004        let mut prompt = Prompt::new("Prompt: ".to_string(), PromptType::Command);
1005        prompt.input = "select this text".to_string();
1006        prompt.cursor_pos = 7; // After "select "
1007
1008        // Select to end
1009        prompt.move_end_selecting();
1010        assert_eq!(prompt.selection_range(), Some((7, 16)));
1011        assert_eq!(prompt.selected_text(), Some("this text".to_string()));
1012
1013        // Clear and select from current position to home
1014        prompt.clear_selection();
1015        prompt.move_home_selecting();
1016        assert_eq!(prompt.selection_range(), Some((0, 16)));
1017        assert_eq!(prompt.selected_text(), Some("select this text".to_string()));
1018    }
1019
1020    #[test]
1021    fn test_word_selection() {
1022        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1023        prompt.input = "one two three".to_string();
1024        prompt.cursor_pos = 4; // After "one "
1025
1026        // Select word right
1027        prompt.move_word_right_selecting();
1028        assert_eq!(prompt.selection_range(), Some((4, 7)));
1029        assert_eq!(prompt.selected_text(), Some("two".to_string()));
1030
1031        // Select another word
1032        prompt.move_word_right_selecting();
1033        assert_eq!(prompt.selection_range(), Some((4, 13)));
1034        assert_eq!(prompt.selected_text(), Some("two three".to_string()));
1035    }
1036
1037    #[test]
1038    fn test_word_selection_backward() {
1039        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1040        prompt.input = "one two three".to_string();
1041        prompt.cursor_pos = 13; // At end
1042
1043        // Select word left - moves to start of "three"
1044        prompt.move_word_left_selecting();
1045        assert_eq!(prompt.selection_range(), Some((8, 13)));
1046        assert_eq!(prompt.selected_text(), Some("three".to_string()));
1047
1048        // Note: Currently, calling move_word_left_selecting again when already
1049        // at a word boundary doesn't move further back. This matches the behavior
1050        // of find_word_start_bytes which finds the start of the current word.
1051        // For multi-word backward selection, move cursor backward first, then select.
1052    }
1053
1054    #[test]
1055    fn test_delete_selection() {
1056        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1057        prompt.input = "hello world".to_string();
1058        prompt.cursor_pos = 5;
1059
1060        // Select " world"
1061        prompt.move_end_selecting();
1062        assert_eq!(prompt.selected_text(), Some(" world".to_string()));
1063
1064        // Delete selection
1065        let deleted = prompt.delete_selection();
1066        assert_eq!(deleted, Some(" world".to_string()));
1067        assert_eq!(prompt.input, "hello");
1068        assert_eq!(prompt.cursor_pos, 5);
1069        assert!(!prompt.has_selection());
1070    }
1071
1072    #[test]
1073    fn test_insert_deletes_selection() {
1074        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1075        prompt.input = "hello world".to_string();
1076        prompt.cursor_pos = 0;
1077
1078        // Select "hello"
1079        for _ in 0..5 {
1080            prompt.move_right_selecting();
1081        }
1082        assert_eq!(prompt.selected_text(), Some("hello".to_string()));
1083
1084        // Insert text - should delete selection first
1085        prompt.insert_str("goodbye");
1086        assert_eq!(prompt.input, "goodbye world");
1087        assert_eq!(prompt.cursor_pos, 7);
1088        assert!(!prompt.has_selection());
1089    }
1090
1091    #[test]
1092    fn test_clear_selection() {
1093        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1094        prompt.input = "test".to_string();
1095        prompt.cursor_pos = 0;
1096
1097        // Create selection
1098        prompt.move_end_selecting();
1099        assert!(prompt.has_selection());
1100
1101        // Clear selection
1102        prompt.clear_selection();
1103        assert!(!prompt.has_selection());
1104        assert_eq!(prompt.cursor_pos, 4); // Cursor should remain at end
1105        assert_eq!(prompt.input, "test"); // Input unchanged
1106    }
1107
1108    #[test]
1109    fn test_selection_edge_cases() {
1110        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1111        prompt.input = "abc".to_string();
1112        prompt.cursor_pos = 3;
1113
1114        // Select beyond end should stop at end (no movement, no selection)
1115        prompt.move_right_selecting();
1116        assert_eq!(prompt.cursor_pos, 3);
1117        // Since cursor didn't move, anchor equals cursor, so no selection
1118        assert_eq!(prompt.selection_range(), None);
1119        assert_eq!(prompt.selected_text(), None);
1120
1121        // Delete non-existent selection should return None
1122        assert_eq!(prompt.delete_selection(), None);
1123        assert_eq!(prompt.input, "abc");
1124    }
1125
1126    #[test]
1127    fn test_selection_with_unicode() {
1128        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1129        prompt.input = "hello 世界 world".to_string();
1130        prompt.cursor_pos = 6; // After "hello "
1131
1132        // Select the Chinese characters
1133        for _ in 0..2 {
1134            prompt.move_right_selecting();
1135        }
1136
1137        let selected = prompt.selected_text().unwrap();
1138        assert_eq!(selected, "世界");
1139
1140        // Delete should work correctly
1141        prompt.delete_selection();
1142        assert_eq!(prompt.input, "hello  world");
1143    }
1144
1145    // BUG REPRODUCTION TESTS
1146
1147    /// Test that Ctrl+Shift+Left continues past first word boundary (was bug #2)
1148    #[test]
1149    fn test_word_selection_continues_across_words() {
1150        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1151        prompt.input = "one two three".to_string();
1152        prompt.cursor_pos = 13; // At end
1153
1154        // First Ctrl+Shift+Left - selects "three"
1155        prompt.move_word_left_selecting();
1156        assert_eq!(prompt.selection_range(), Some((8, 13)));
1157        assert_eq!(prompt.selected_text(), Some("three".to_string()));
1158
1159        // Second Ctrl+Shift+Left - should extend to "two three"
1160        // Now correctly moves back one more word when already at word boundary
1161        prompt.move_word_left_selecting();
1162
1163        // Selection should extend to include "two three"
1164        assert_eq!(prompt.selection_range(), Some((4, 13)));
1165        assert_eq!(prompt.selected_text(), Some("two three".to_string()));
1166    }
1167
1168    // Property-based tests for Prompt operations
1169    #[cfg(test)]
1170    mod property_tests {
1171        use super::*;
1172        use proptest::prelude::*;
1173
1174        proptest! {
1175            /// Property: delete_word_backward should never increase input length
1176            #[test]
1177            fn prop_delete_word_backward_shrinks(
1178                input in "[a-zA-Z0-9_ ]{0,50}",
1179                cursor_pos in 0usize..50
1180            ) {
1181                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1182                prompt.input = input.clone();
1183                prompt.cursor_pos = cursor_pos.min(input.len());
1184
1185                let original_len = prompt.input.len();
1186                prompt.delete_word_backward();
1187
1188                prop_assert!(prompt.input.len() <= original_len);
1189            }
1190
1191            /// Property: delete_word_forward should never increase input length
1192            #[test]
1193            fn prop_delete_word_forward_shrinks(
1194                input in "[a-zA-Z0-9_ ]{0,50}",
1195                cursor_pos in 0usize..50
1196            ) {
1197                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1198                prompt.input = input.clone();
1199                prompt.cursor_pos = cursor_pos.min(input.len());
1200
1201                let original_len = prompt.input.len();
1202                prompt.delete_word_forward();
1203
1204                prop_assert!(prompt.input.len() <= original_len);
1205            }
1206
1207            /// Property: delete_word_backward should not move cursor past input start
1208            #[test]
1209            fn prop_delete_word_backward_cursor_valid(
1210                input in "[a-zA-Z0-9_ ]{0,50}",
1211                cursor_pos in 0usize..50
1212            ) {
1213                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1214                prompt.input = input.clone();
1215                prompt.cursor_pos = cursor_pos.min(input.len());
1216
1217                prompt.delete_word_backward();
1218
1219                prop_assert!(prompt.cursor_pos <= prompt.input.len());
1220            }
1221
1222            /// Property: delete_word_forward should keep cursor in valid range
1223            #[test]
1224            fn prop_delete_word_forward_cursor_valid(
1225                input in "[a-zA-Z0-9_ ]{0,50}",
1226                cursor_pos in 0usize..50
1227            ) {
1228                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1229                prompt.input = input.clone();
1230                prompt.cursor_pos = cursor_pos.min(input.len());
1231
1232                prompt.delete_word_forward();
1233
1234                prop_assert!(prompt.cursor_pos <= prompt.input.len());
1235            }
1236
1237            /// Property: insert_str should increase length by inserted text length
1238            #[test]
1239            fn prop_insert_str_length(
1240                input in "[a-zA-Z0-9_ ]{0,30}",
1241                insert in "[a-zA-Z0-9_ ]{0,20}",
1242                cursor_pos in 0usize..30
1243            ) {
1244                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1245                prompt.input = input.clone();
1246                prompt.cursor_pos = cursor_pos.min(input.len());
1247
1248                let original_len = prompt.input.len();
1249                prompt.insert_str(&insert);
1250
1251                prop_assert_eq!(prompt.input.len(), original_len + insert.len());
1252            }
1253
1254            /// Property: insert_str should move cursor by inserted text length
1255            #[test]
1256            fn prop_insert_str_cursor(
1257                input in "[a-zA-Z0-9_ ]{0,30}",
1258                insert in "[a-zA-Z0-9_ ]{0,20}",
1259                cursor_pos in 0usize..30
1260            ) {
1261                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1262                prompt.input = input.clone();
1263                let original_pos = cursor_pos.min(input.len());
1264                prompt.cursor_pos = original_pos;
1265
1266                prompt.insert_str(&insert);
1267
1268                prop_assert_eq!(prompt.cursor_pos, original_pos + insert.len());
1269            }
1270
1271            /// Property: clear should always result in empty string and zero cursor
1272            #[test]
1273            fn prop_clear_resets(input in "[a-zA-Z0-9_ ]{0,50}") {
1274                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1275                prompt.input = input;
1276                prompt.cursor_pos = prompt.input.len();
1277
1278                prompt.clear();
1279
1280                prop_assert_eq!(prompt.input, "");
1281                prop_assert_eq!(prompt.cursor_pos, 0);
1282            }
1283        }
1284    }
1285}