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