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