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