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