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