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    /// Confirm quitting on a clean session (opt-in via `editor.confirm_quit`).
124    /// Issued only when no buffer is modified; otherwise
125    /// `ConfirmQuitWithModified` runs instead.
126    ConfirmQuit,
127    /// File Explorer rename operation
128    /// Stores the original path and name for the file/directory being renamed
129    FileExplorerRename {
130        original_path: std::path::PathBuf,
131        original_name: String,
132        /// True if this rename is for a newly created file (should switch focus to editor after)
133        /// False if renaming an existing file (should keep focus in file explorer)
134        is_new_file: bool,
135    },
136    /// Confirm deleting a file or directory in the file explorer
137    ConfirmDeleteFile {
138        path: std::path::PathBuf,
139        is_dir: bool,
140    },
141    /// Confirm overwriting, renaming, or cancelling a paste conflict
142    ConfirmPasteConflict {
143        src: std::path::PathBuf,
144        dst: std::path::PathBuf,
145        is_cut: bool,
146    },
147    /// Rename destination when pasting (user chose 'r' in conflict prompt)
148    FileExplorerPasteRename {
149        src: std::path::PathBuf,
150        dst_dir: std::path::PathBuf,
151        is_cut: bool,
152    },
153    /// Confirm deleting multiple items from the file explorer
154    ConfirmMultiDelete { paths: Vec<std::path::PathBuf> },
155    /// Per-conflict prompt for multi-file paste.
156    /// `pending[0]` is the conflict currently being shown.
157    /// User choices: (o)verwrite this, (O) all, (s)kip this, (S) all, (c)ancel.
158    ConfirmMultiPasteConflict {
159        safe: Vec<(std::path::PathBuf, std::path::PathBuf)>,
160        confirmed: Vec<(std::path::PathBuf, std::path::PathBuf)>,
161        pending: Vec<(std::path::PathBuf, std::path::PathBuf)>,
162        is_cut: bool,
163    },
164    /// Confirm loading a large file with non-resynchronizable encoding
165    /// (like GB18030, GBK, Shift-JIS, EUC-KR) that requires full file loading
166    ConfirmLargeFileEncoding { path: std::path::PathBuf },
167    /// Switch to a tab by name (from the current split's open buffers)
168    SwitchToTab,
169    /// Run shell command on buffer/selection
170    /// If replace is true, replace the input with the output
171    /// If replace is false, output goes to a new buffer
172    ShellCommand { replace: bool },
173    /// Async prompt from plugin (for editor.prompt() API)
174    /// The result is returned via callback resolution
175    AsyncPrompt,
176}
177
178impl PromptType {
179    /// Whether a mouse click on a suggestion should immediately confirm.
180    ///
181    /// Defaults to `true` (matches command palette / file finder UX). Returns
182    /// `false` for prompts that pick from a small fixed list and trigger an
183    /// expensive or destructive action — there, click should preview the
184    /// selection and Enter should commit (issue #1660).
185    pub fn click_confirms(&self) -> bool {
186        !matches!(self, PromptType::ReloadWithEncoding)
187    }
188
189    /// Whether this prompt is one of the search/replace prompts that exposes
190    /// the match-mode toggles (case sensitive / whole word / regex).
191    ///
192    /// This is the single source of truth for "are search options in scope":
193    /// it gates both the rendering of the search-options bar and the
194    /// `ToggleSearch*` actions, so the toggle keys are inert in unrelated
195    /// prompts like the (s)ave/(d)iscard/(C)ancel close confirmation
196    /// (otherwise Alt+W there would silently flip whole-word match mode —
197    /// see issue with Alt+W leaking into the close-buffer prompt).
198    pub fn has_search_options(&self) -> bool {
199        matches!(
200            self,
201            PromptType::Search
202                | PromptType::ReplaceSearch
203                | PromptType::Replace { .. }
204                | PromptType::QueryReplaceSearch
205                | PromptType::QueryReplace { .. }
206        )
207    }
208}
209
210/// Prompt state for the minibuffer
211#[derive(Debug, Clone)]
212pub struct Prompt {
213    /// The prompt message (e.g., "Find file: ")
214    pub message: String,
215    /// User's current input
216    pub input: String,
217    /// Cursor position in the input
218    pub cursor_pos: usize,
219    /// What to do when user confirms
220    pub prompt_type: PromptType,
221    /// Autocomplete suggestions (filtered)
222    pub suggestions: Vec<Suggestion>,
223    /// Original unfiltered suggestions (for prompts that filter client-side like SwitchToTab)
224    pub original_suggestions: Option<Vec<Suggestion>>,
225    /// Currently selected suggestion index
226    pub selected_suggestion: Option<usize>,
227    /// Index of the first suggestion shown in the popup viewport.
228    /// Updated minimally by the renderer to keep `selected_suggestion`
229    /// visible — selection changes inside the viewport never scroll
230    /// (issue #1660).
231    pub scroll_offset: usize,
232    /// When true, the user has scrolled the result list with the mouse wheel,
233    /// so the renderer must NOT pull `scroll_offset` back to keep the
234    /// selection in view (issue #2119). Reset whenever the selection moves by
235    /// keyboard or the suggestion list is rebuilt, so normal navigation
236    /// re-engages the keep-selection-visible behaviour.
237    pub manual_scroll: bool,
238    /// Selection anchor position (for Shift+Arrow selection)
239    /// When Some(pos), there's a selection from anchor to cursor_pos
240    pub selection_anchor: Option<usize>,
241    /// Tracks the input value when suggestions were last set by a plugin.
242    /// Used to skip Rust-side filtering when plugin has already filtered for this input.
243    pub suggestions_set_for_input: Option<String>,
244    /// When true, navigating suggestions updates the input text (selected) to match.
245    /// Used by plugin prompts that want picker-like behavior (e.g. compose width).
246    pub sync_input_on_navigate: bool,
247    /// When true, the renderer draws the prompt inside a centred
248    /// floating overlay (PopupPosition::CenteredOverlay) instead of
249    /// the bottom minibuffer row. Set by the live-grep plugin via the
250    /// `floatingOverlay` flag on `editor.startPrompt(...)`. The flag
251    /// is rendering-only — confirm/cancel/hooks behave identically to
252    /// a non-overlay prompt of the same `prompt_type`.
253    pub overlay: bool,
254    /// Title shown in the overlay's frame header as styled
255    /// segments. An empty vec falls back to the `prompt_type`-
256    /// specific default. Plugin-controlled via
257    /// `editor.setPromptTitle(segments)`. Has no effect on
258    /// non-overlay prompts.
259    pub title: Vec<fresh_core::api::StyledText>,
260    /// Optional footer chrome shown along the bottom of the
261    /// floating overlay's results pane (above the frame border).
262    /// Plugin-controlled via `editor.setPromptFooter(segments)`.
263    /// Orchestrator uses this for hotkey-hint rows
264    /// (e.g. " [n] new   [d] dive   [k] kill   [Esc] close").
265    /// Empty by default; has no effect on non-overlay prompts.
266    /// Implements the chrome-region piece of Primitive #2 in
267    /// docs/internal/orchestrator-sessions-design.md (the
268    /// session_preview delegate region was already provided by
269    /// Primitive #1 — `editor.previewWindowInRect`).
270    pub footer: Vec<fresh_core::api::StyledText>,
271    /// Undo history for the input field: `(input, cursor_pos)` snapshots
272    /// captured before each text mutation. Ctrl+Z pops from here. Kept
273    /// local to the prompt so undo edits the query box rather than the
274    /// underlying (modal-inaccessible) buffer.
275    undo_stack: Vec<(String, usize)>,
276    /// Redo counterpart to `undo_stack`. Cleared on any fresh mutation.
277    redo_stack: Vec<(String, usize)>,
278    /// Optional toolbar for the overlay's header band, as real widgets
279    /// (`Toggle`/`Button` in a `Row`/`Col`). When `Some`, it is rendered via
280    /// the widget engine *in place of* the styled-text `title`, so the
281    /// controls are themed and clickable. Plugin-controlled via
282    /// `editor.setPromptToolbar(spec)`. No effect on non-overlay prompts.
283    pub toolbar_widget: Option<fresh_core::api::WidgetSpec>,
284    /// Overlay focus ring position: `None` = the query input is focused
285    /// (typing edits the query, the caret shows there); `Some(key)` = that
286    /// toolbar control is focused (Space/Enter toggles it, it renders
287    /// highlighted). Tab/Shift+Tab cycle input → toggles → input.
288    pub toolbar_focus: Option<String>,
289    /// Short status shown right-aligned on the input row, just left of the
290    /// `selected / total` count (e.g. "Searching…", "No matches"). Plugin-
291    /// controlled via `editor.setPromptStatus(text)`; overlay-only.
292    pub status: String,
293}
294
295/// Maximum number of suggestion rows shown at once. Mirrors the cap used by
296/// `SuggestionsRenderer` so `Prompt::ensure_selected_visible` can compute the
297/// viewport size without inspecting render state.
298pub const MAX_VISIBLE_SUGGESTIONS: usize = 10;
299
300impl Prompt {
301    /// Create a new prompt
302    pub fn new(message: String, prompt_type: PromptType) -> Self {
303        Self {
304            message,
305            input: String::new(),
306            cursor_pos: 0,
307            prompt_type,
308            suggestions: Vec::new(),
309            original_suggestions: None,
310            selected_suggestion: None,
311            scroll_offset: 0,
312            manual_scroll: false,
313            selection_anchor: None,
314            suggestions_set_for_input: None,
315            sync_input_on_navigate: false,
316            overlay: false,
317            title: Vec::new(),
318            footer: Vec::new(),
319            undo_stack: Vec::new(),
320            redo_stack: Vec::new(),
321            toolbar_widget: None,
322            toolbar_focus: None,
323            status: String::new(),
324        }
325    }
326
327    /// Create a new prompt with suggestions
328    ///
329    /// The suggestions are stored both as the current filtered list and as the original
330    /// unfiltered list (for prompts that filter client-side like SwitchToTab).
331    pub fn with_suggestions(
332        message: String,
333        prompt_type: PromptType,
334        suggestions: Vec<Suggestion>,
335    ) -> Self {
336        let selected_suggestion = if suggestions.is_empty() {
337            None
338        } else {
339            Some(0)
340        };
341        Self {
342            message,
343            input: String::new(),
344            cursor_pos: 0,
345            prompt_type,
346            original_suggestions: Some(suggestions.clone()),
347            suggestions,
348            selected_suggestion,
349            scroll_offset: 0,
350            manual_scroll: false,
351            selection_anchor: None,
352            suggestions_set_for_input: None,
353            sync_input_on_navigate: false,
354            overlay: false,
355            title: Vec::new(),
356            footer: Vec::new(),
357            undo_stack: Vec::new(),
358            redo_stack: Vec::new(),
359            toolbar_widget: None,
360            toolbar_focus: None,
361            status: String::new(),
362        }
363    }
364
365    /// Create a new prompt with initial text, cursor at end, ready for
366    /// incremental editing (no selection). Use for rename-style flows where
367    /// the user typically keeps most of the prefilled name and only
368    /// appends or tweaks a suffix.
369    pub fn with_initial_text_for_edit(
370        message: String,
371        prompt_type: PromptType,
372        initial_text: String,
373    ) -> Self {
374        Self::with_initial_text_inner(message, prompt_type, initial_text, false)
375    }
376
377    /// Create a new prompt with initial text (selected so typing replaces it)
378    pub fn with_initial_text(
379        message: String,
380        prompt_type: PromptType,
381        initial_text: String,
382    ) -> Self {
383        Self::with_initial_text_inner(message, prompt_type, initial_text, true)
384    }
385
386    fn with_initial_text_inner(
387        message: String,
388        prompt_type: PromptType,
389        initial_text: String,
390        select_all: bool,
391    ) -> Self {
392        let cursor_pos = initial_text.len();
393        let selection_anchor = if select_all && !initial_text.is_empty() {
394            Some(0)
395        } else {
396            None
397        };
398        Self {
399            message,
400            input: initial_text,
401            cursor_pos,
402            prompt_type,
403            suggestions: Vec::new(),
404            original_suggestions: None,
405            selected_suggestion: None,
406            scroll_offset: 0,
407            manual_scroll: false,
408            selection_anchor,
409            suggestions_set_for_input: None,
410            sync_input_on_navigate: false,
411            overlay: false,
412            title: Vec::new(),
413            footer: Vec::new(),
414            undo_stack: Vec::new(),
415            redo_stack: Vec::new(),
416            toolbar_widget: None,
417            toolbar_focus: None,
418            status: String::new(),
419        }
420    }
421
422    /// Move cursor left (to previous grapheme cluster boundary)
423    ///
424    /// Uses grapheme cluster boundaries for proper handling of combining characters
425    /// like Thai diacritics, emoji with modifiers, etc.
426    pub fn cursor_left(&mut self) {
427        if self.cursor_pos > 0 {
428            self.cursor_pos = grapheme::prev_grapheme_boundary(&self.input, self.cursor_pos);
429        }
430    }
431
432    /// Move cursor right (to next grapheme cluster boundary)
433    ///
434    /// Uses grapheme cluster boundaries for proper handling of combining characters
435    /// like Thai diacritics, emoji with modifiers, etc.
436    pub fn cursor_right(&mut self) {
437        if self.cursor_pos < self.input.len() {
438            self.cursor_pos = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
439        }
440    }
441
442    /// Capture the current `(input, cursor_pos)` for undo, and drop any
443    /// redo history. Call at the start of every text-mutating operation.
444    /// No-ops if the input is unchanged from the most recent snapshot so
445    /// repeated no-op edits don't bloat the stack.
446    fn push_undo_snapshot(&mut self) {
447        if self
448            .undo_stack
449            .last()
450            .is_some_and(|(text, _)| *text == self.input)
451        {
452            return;
453        }
454        // Bound the history so a very long editing session can't grow it
455        // without limit.
456        const MAX_UNDO: usize = 500;
457        if self.undo_stack.len() >= MAX_UNDO {
458            self.undo_stack.remove(0);
459        }
460        self.undo_stack.push((self.input.clone(), self.cursor_pos));
461        self.redo_stack.clear();
462    }
463
464    /// Undo the last input edit. Returns true if the input changed.
465    pub fn undo_input(&mut self) -> bool {
466        if let Some((text, cursor)) = self.undo_stack.pop() {
467            self.redo_stack.push((self.input.clone(), self.cursor_pos));
468            self.input = text;
469            self.cursor_pos = cursor.min(self.input.len());
470            self.selection_anchor = None;
471            true
472        } else {
473            false
474        }
475    }
476
477    /// Redo the last undone input edit. Returns true if the input changed.
478    pub fn redo_input(&mut self) -> bool {
479        if let Some((text, cursor)) = self.redo_stack.pop() {
480            self.undo_stack.push((self.input.clone(), self.cursor_pos));
481            self.input = text;
482            self.cursor_pos = cursor.min(self.input.len());
483            self.selection_anchor = None;
484            true
485        } else {
486            false
487        }
488    }
489
490    /// Insert a character at the cursor position
491    pub fn insert_char(&mut self, ch: char) {
492        self.push_undo_snapshot();
493        self.input.insert(self.cursor_pos, ch);
494        self.cursor_pos += ch.len_utf8();
495    }
496
497    /// Delete one code point before cursor (backspace)
498    ///
499    /// Deletes one Unicode code point at a time, allowing layer-by-layer deletion
500    /// of combining characters. For Thai text, this means you can delete just the
501    /// tone mark without removing the base consonant.
502    pub fn backspace(&mut self) {
503        if self.cursor_pos > 0 {
504            self.push_undo_snapshot();
505            // Find the previous character (code point) boundary, not grapheme boundary
506            // This allows layer-by-layer deletion of combining marks
507            let prev_boundary = self.input[..self.cursor_pos]
508                .char_indices()
509                .next_back()
510                .map(|(i, _)| i)
511                .unwrap_or(0);
512            self.input.drain(prev_boundary..self.cursor_pos);
513            self.cursor_pos = prev_boundary;
514        }
515    }
516
517    /// Delete grapheme cluster at cursor (delete key)
518    ///
519    /// Deletes the entire grapheme cluster, handling combining characters properly.
520    pub fn delete(&mut self) {
521        if self.cursor_pos < self.input.len() {
522            self.push_undo_snapshot();
523            let next_boundary = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
524            self.input.drain(self.cursor_pos..next_boundary);
525        }
526    }
527
528    /// Move to start of input
529    pub fn move_to_start(&mut self) {
530        self.cursor_pos = 0;
531    }
532
533    /// Move to end of input
534    pub fn move_to_end(&mut self) {
535        self.cursor_pos = self.input.len();
536    }
537
538    /// Set the input text and cursor position
539    ///
540    /// Used for history navigation - replaces the entire input with a new value
541    /// and moves cursor to the end.
542    ///
543    /// # Example
544    /// ```
545    /// # use fresh::prompt::{Prompt, PromptType};
546    /// let mut prompt = Prompt::new("Search: ".to_string(), PromptType::Search);
547    /// prompt.input = "current".to_string();
548    /// prompt.cursor_pos = 7;
549    ///
550    /// prompt.set_input("from history".to_string());
551    /// assert_eq!(prompt.input, "from history");
552    /// assert_eq!(prompt.cursor_pos, 12); // At end
553    /// ```
554    pub fn set_input(&mut self, text: String) {
555        self.push_undo_snapshot();
556        self.cursor_pos = text.len();
557        self.input = text;
558        self.clear_selection();
559    }
560
561    /// Select next suggestion
562    pub fn select_next_suggestion(&mut self) {
563        if !self.suggestions.is_empty() {
564            // Keyboard navigation re-engages keep-selection-visible scrolling.
565            self.manual_scroll = false;
566            self.selected_suggestion = Some(match self.selected_suggestion {
567                Some(idx) if idx + 1 < self.suggestions.len() => idx + 1,
568                Some(_) => 0, // Wrap to start
569                None => 0,
570            });
571        }
572    }
573
574    /// Select previous suggestion
575    pub fn select_prev_suggestion(&mut self) {
576        if !self.suggestions.is_empty() {
577            self.manual_scroll = false;
578            self.selected_suggestion = Some(match self.selected_suggestion {
579                Some(0) => self.suggestions.len() - 1, // Wrap to end
580                Some(idx) => idx - 1,
581                None => 0,
582            });
583        }
584    }
585
586    /// Scroll the result list by `delta` rows without moving the selection
587    /// (mouse wheel over the Live Grep overlay results pane, issue #2119).
588    /// `visible` is the number of result rows currently on screen, used to
589    /// clamp the offset so it can't scroll past the end of the list.
590    pub fn scroll_results(&mut self, delta: i32, visible: usize) {
591        let total = self.suggestions.len();
592        if total == 0 {
593            return;
594        }
595        let max_offset = total.saturating_sub(visible.max(1));
596        let next = (self.scroll_offset as i32 + delta).clamp(0, max_offset as i32) as usize;
597        if next != self.scroll_offset {
598            self.scroll_offset = next;
599        }
600        // Latch manual scroll even when clamped at an edge, so a follow-up
601        // render doesn't immediately yank the offset back to the selection.
602        self.manual_scroll = true;
603    }
604
605    /// Get the currently selected suggestion value
606    pub fn selected_value(&self) -> Option<String> {
607        self.selected_suggestion
608            .and_then(|idx| self.suggestions.get(idx))
609            .map(|s| s.get_value().to_string())
610    }
611
612    /// Get the final input (use selected suggestion if available, otherwise raw input)
613    pub fn get_final_input(&self) -> String {
614        self.selected_value().unwrap_or_else(|| self.input.clone())
615    }
616
617    /// Apply fuzzy filtering to suggestions based on current input
618    ///
619    /// If `match_description` is true, also matches against suggestion descriptions.
620    /// Updates `suggestions` with filtered and sorted results.
621    pub fn filter_suggestions(&mut self, match_description: bool) {
622        use crate::input::fuzzy::{fuzzy_match, FuzzyMatch};
623
624        // Skip filtering if the plugin has already set suggestions for this exact input.
625        // This handles the race condition where run_hook("prompt_changed") is async:
626        // the plugin may have already responded with filtered results via setPromptSuggestions.
627        if let Some(ref set_for_input) = self.suggestions_set_for_input {
628            if set_for_input == &self.input {
629                return;
630            }
631        }
632        // Input has diverged from whatever the plugin pre-filtered
633        // for — invalidate the marker so a later return to that
634        // same input doesn't reuse a now-stale list.
635        self.suggestions_set_for_input = None;
636
637        let Some(original) = &self.original_suggestions else {
638            return;
639        };
640
641        let input = &self.input;
642        let mut filtered: Vec<(crate::input::commands::Suggestion, i32)> = original
643            .iter()
644            .filter_map(|s| {
645                let text_result = fuzzy_match(input, &s.text);
646                let desc_result = if match_description {
647                    s.description
648                        .as_ref()
649                        .map(|d| fuzzy_match(input, d))
650                        .unwrap_or_else(FuzzyMatch::no_match)
651                } else {
652                    FuzzyMatch::no_match()
653                };
654                if text_result.matched || desc_result.matched {
655                    Some((s.clone(), text_result.score.max(desc_result.score)))
656                } else {
657                    None
658                }
659            })
660            .collect();
661
662        filtered.sort_by(|a, b| b.1.cmp(&a.1));
663        self.suggestions = filtered.into_iter().map(|(s, _)| s).collect();
664        self.selected_suggestion = if self.suggestions.is_empty() {
665            None
666        } else {
667            Some(0)
668        };
669        self.scroll_offset = 0;
670        self.manual_scroll = false;
671    }
672
673    /// Adjust `scroll_offset` so that `selected_suggestion` is inside the
674    /// viewport, scrolling the minimum amount required. A selection that's
675    /// already on-screen leaves the viewport untouched — this is what stops
676    /// a click on a near-bottom item from snapping the list upward and
677    /// recentering under the cursor (issue #1660).
678    ///
679    /// Uses the bottom-popup default cap (`MAX_VISIBLE_SUGGESTIONS`).
680    /// Callers rendering into a different-sized area (e.g. the
681    /// floating Live Grep overlay, where the suggestion list can be
682    /// 30+ rows tall) should call
683    /// [`ensure_selected_visible_within`] with the actual height
684    /// instead — otherwise the scroll moves prematurely once the
685    /// selection passes the 10th row even though the rest of the
686    /// list is still visible on-screen.
687    pub fn ensure_selected_visible(&mut self) {
688        self.ensure_selected_visible_within(MAX_VISIBLE_SUGGESTIONS);
689    }
690
691    /// Like [`ensure_selected_visible`] but with an explicit
692    /// `visible_count` argument, so renderers in differently-sized
693    /// frames don't all share the bottom-popup `MAX_VISIBLE_SUGGESTIONS`
694    /// assumption.
695    pub fn ensure_selected_visible_within(&mut self, visible_count: usize) {
696        let total = self.suggestions.len();
697        let visible = total.min(visible_count.max(1));
698        let max_offset = total.saturating_sub(visible);
699        if visible == 0 {
700            self.scroll_offset = 0;
701            return;
702        }
703        if let Some(selected) = self.selected_suggestion {
704            if selected < self.scroll_offset {
705                self.scroll_offset = selected;
706            } else if selected >= self.scroll_offset + visible {
707                self.scroll_offset = selected + 1 - visible;
708            }
709        }
710        if self.scroll_offset > max_offset {
711            self.scroll_offset = max_offset;
712        }
713    }
714
715    // ========================================================================
716    // Advanced editing operations (word-based, clipboard)
717    // ========================================================================
718    //
719    // MOTIVATION:
720    // These methods provide advanced editing capabilities in prompts that
721    // users expect from normal text editing:
722    // - Word-based deletion (Ctrl+Backspace/Delete)
723    // - Copy/paste/cut operations
724    //
725    // This enables consistent editing experience across both buffer editing
726    // and prompt input (command palette, file picker, search, etc.).
727
728    /// Delete from cursor to end of word (Ctrl+Delete).
729    ///
730    /// Deletes from the current cursor position to the end of the current word.
731    /// If the cursor is at a non-word character, skips to the next word and
732    /// deletes to its end.
733    ///
734    /// # Example
735    /// ```
736    /// # use fresh::prompt::{Prompt, PromptType};
737    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
738    /// prompt.input = "hello world".to_string();
739    /// prompt.cursor_pos = 0; // At start of "hello"
740    /// prompt.delete_word_forward();
741    /// assert_eq!(prompt.input, " world");
742    /// assert_eq!(prompt.cursor_pos, 0);
743    /// ```
744    pub fn delete_word_forward(&mut self) {
745        let word_end = find_word_end_bytes(self.input.as_bytes(), self.cursor_pos);
746        if word_end > self.cursor_pos {
747            self.push_undo_snapshot();
748            self.input.drain(self.cursor_pos..word_end);
749            // Cursor stays at same position
750        }
751    }
752
753    /// Delete from start of word to cursor (Ctrl+Backspace).
754    ///
755    /// Deletes from the start of the current word to the cursor position.
756    /// If the cursor is after a non-word character, deletes the previous word.
757    ///
758    /// # Example
759    /// ```
760    /// # use fresh::prompt::{Prompt, PromptType};
761    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
762    /// prompt.input = "hello world".to_string();
763    /// prompt.cursor_pos = 5; // After "hello"
764    /// prompt.delete_word_backward();
765    /// assert_eq!(prompt.input, " world");
766    /// assert_eq!(prompt.cursor_pos, 0);
767    /// ```
768    pub fn delete_word_backward(&mut self) {
769        let word_start = find_word_start_bytes(self.input.as_bytes(), self.cursor_pos);
770        if word_start < self.cursor_pos {
771            self.push_undo_snapshot();
772            self.input.drain(word_start..self.cursor_pos);
773            self.cursor_pos = word_start;
774        }
775    }
776
777    /// Delete from cursor to end of line (Ctrl+K).
778    ///
779    /// Deletes all text from the cursor position to the end of the input.
780    ///
781    /// # Example
782    /// ```
783    /// # use fresh::prompt::{Prompt, PromptType};
784    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
785    /// prompt.input = "hello world".to_string();
786    /// prompt.cursor_pos = 5; // After "hello"
787    /// prompt.delete_to_end();
788    /// assert_eq!(prompt.input, "hello");
789    /// assert_eq!(prompt.cursor_pos, 5);
790    /// ```
791    pub fn delete_to_end(&mut self) {
792        if self.cursor_pos < self.input.len() {
793            self.push_undo_snapshot();
794            self.input.truncate(self.cursor_pos);
795        }
796    }
797
798    /// Delete from the cursor back to the start of the line (Ctrl+U).
799    ///
800    /// Mirrors the standard readline kill-to-start behavior so the
801    /// command palette can be cleared without holding Backspace.
802    pub fn delete_to_start(&mut self) {
803        if self.cursor_pos > 0 {
804            self.push_undo_snapshot();
805            self.input.drain(..self.cursor_pos);
806            self.cursor_pos = 0;
807        }
808    }
809
810    /// Get the current input text (for copy operation).
811    ///
812    /// Returns a copy of the entire input. In future, this could be extended
813    /// to support selection ranges for copying only selected text.
814    ///
815    /// # Example
816    /// ```
817    /// # use fresh::prompt::{Prompt, PromptType};
818    /// let mut prompt = Prompt::new("Search: ".to_string(), PromptType::Search);
819    /// prompt.input = "test query".to_string();
820    /// assert_eq!(prompt.get_text(), "test query");
821    /// ```
822    pub fn get_text(&self) -> String {
823        self.input.clone()
824    }
825
826    /// Clear the input (used for cut operation).
827    ///
828    /// Removes all text from the input and resets cursor to start.
829    ///
830    /// # Example
831    /// ```
832    /// # use fresh::prompt::{Prompt, PromptType};
833    /// let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
834    /// prompt.input = "some text".to_string();
835    /// prompt.cursor_pos = 9;
836    /// prompt.clear();
837    /// assert_eq!(prompt.input, "");
838    /// assert_eq!(prompt.cursor_pos, 0);
839    /// ```
840    pub fn clear(&mut self) {
841        self.input.clear();
842        self.cursor_pos = 0;
843        // Also clear selection when clearing input
844        self.selected_suggestion = None;
845    }
846
847    /// Insert text at cursor position (used for paste operation).
848    ///
849    /// Inserts the given text at the current cursor position and moves
850    /// the cursor to the end of the inserted text.
851    ///
852    /// # Example
853    /// ```
854    /// # use fresh::prompt::{Prompt, PromptType};
855    /// let mut prompt = Prompt::new("Command: ".to_string(), PromptType::QuickOpen);
856    /// prompt.input = "save".to_string();
857    /// prompt.cursor_pos = 4;
858    /// prompt.insert_str(" file");
859    /// assert_eq!(prompt.input, "save file");
860    /// assert_eq!(prompt.cursor_pos, 9);
861    /// ```
862    pub fn insert_str(&mut self, text: &str) {
863        // If there's a selection, delete it first
864        if self.has_selection() {
865            self.delete_selection();
866        }
867        self.input.insert_str(self.cursor_pos, text);
868        self.cursor_pos += text.len();
869    }
870
871    // ========================================================================
872    // Selection support
873    // ========================================================================
874
875    /// Check if there's an active selection
876    pub fn has_selection(&self) -> bool {
877        self.selection_anchor.is_some() && self.selection_anchor != Some(self.cursor_pos)
878    }
879
880    /// Get the selection range (start, end) where start <= end
881    pub fn selection_range(&self) -> Option<(usize, usize)> {
882        if let Some(anchor) = self.selection_anchor {
883            if anchor != self.cursor_pos {
884                let start = anchor.min(self.cursor_pos);
885                let end = anchor.max(self.cursor_pos);
886                return Some((start, end));
887            }
888        }
889        None
890    }
891
892    /// Get the selected text
893    pub fn selected_text(&self) -> Option<String> {
894        self.selection_range()
895            .map(|(start, end)| self.input[start..end].to_string())
896    }
897
898    /// Delete the current selection and return the deleted text
899    pub fn delete_selection(&mut self) -> Option<String> {
900        if let Some((start, end)) = self.selection_range() {
901            self.push_undo_snapshot();
902            let deleted = self.input[start..end].to_string();
903            self.input.drain(start..end);
904            self.cursor_pos = start;
905            self.selection_anchor = None;
906            Some(deleted)
907        } else {
908            None
909        }
910    }
911
912    /// Clear selection without deleting text
913    pub fn clear_selection(&mut self) {
914        self.selection_anchor = None;
915    }
916
917    /// Move cursor left with selection (by grapheme cluster)
918    pub fn move_left_selecting(&mut self) {
919        // Set anchor if not already set
920        if self.selection_anchor.is_none() {
921            self.selection_anchor = Some(self.cursor_pos);
922        }
923
924        // Move cursor left by grapheme cluster
925        if self.cursor_pos > 0 {
926            self.cursor_pos = grapheme::prev_grapheme_boundary(&self.input, self.cursor_pos);
927        }
928    }
929
930    /// Move cursor right with selection (by grapheme cluster)
931    pub fn move_right_selecting(&mut self) {
932        // Set anchor if not already set
933        if self.selection_anchor.is_none() {
934            self.selection_anchor = Some(self.cursor_pos);
935        }
936
937        // Move cursor right by grapheme cluster
938        if self.cursor_pos < self.input.len() {
939            self.cursor_pos = grapheme::next_grapheme_boundary(&self.input, self.cursor_pos);
940        }
941    }
942
943    /// Move to start of input with selection
944    pub fn move_home_selecting(&mut self) {
945        if self.selection_anchor.is_none() {
946            self.selection_anchor = Some(self.cursor_pos);
947        }
948        self.cursor_pos = 0;
949    }
950
951    /// Move to end of input with selection
952    pub fn move_end_selecting(&mut self) {
953        if self.selection_anchor.is_none() {
954            self.selection_anchor = Some(self.cursor_pos);
955        }
956        self.cursor_pos = self.input.len();
957    }
958
959    /// Move to start of previous word with selection
960    /// Mimics Buffer's find_word_start_left behavior
961    pub fn move_word_left_selecting(&mut self) {
962        if self.selection_anchor.is_none() {
963            self.selection_anchor = Some(self.cursor_pos);
964        }
965
966        let bytes = self.input.as_bytes();
967        if self.cursor_pos == 0 {
968            return;
969        }
970
971        let mut new_pos = self.cursor_pos.saturating_sub(1);
972
973        // Skip non-word characters (spaces) backwards
974        while new_pos > 0 && !is_word_char(bytes[new_pos]) {
975            new_pos = new_pos.saturating_sub(1);
976        }
977
978        // Find start of word
979        while new_pos > 0 && is_word_char(bytes[new_pos.saturating_sub(1)]) {
980            new_pos = new_pos.saturating_sub(1);
981        }
982
983        self.cursor_pos = new_pos;
984    }
985
986    /// Move to end of next word with selection
987    /// For selection, we want to select whole words, so move to word END, not word START
988    pub fn move_word_right_selecting(&mut self) {
989        if self.selection_anchor.is_none() {
990            self.selection_anchor = Some(self.cursor_pos);
991        }
992
993        // Use find_word_end_bytes which moves to the END of words
994        let bytes = self.input.as_bytes();
995        let mut new_pos = find_word_end_bytes(bytes, self.cursor_pos);
996
997        // If we didn't move (already at word end), move forward to next word end
998        if new_pos == self.cursor_pos && new_pos < bytes.len() {
999            new_pos = (new_pos + 1).min(bytes.len());
1000            new_pos = find_word_end_bytes(bytes, new_pos);
1001        }
1002
1003        self.cursor_pos = new_pos;
1004    }
1005
1006    /// Move to start of previous word (without selection)
1007    /// Mimics Buffer's find_word_start_left behavior
1008    pub fn move_word_left(&mut self) {
1009        self.clear_selection();
1010
1011        let bytes = self.input.as_bytes();
1012        if self.cursor_pos == 0 {
1013            return;
1014        }
1015
1016        let mut new_pos = self.cursor_pos.saturating_sub(1);
1017
1018        // Skip non-word characters (spaces) backwards
1019        while new_pos > 0 && !is_word_char(bytes[new_pos]) {
1020            new_pos = new_pos.saturating_sub(1);
1021        }
1022
1023        // Find start of word
1024        while new_pos > 0 && is_word_char(bytes[new_pos.saturating_sub(1)]) {
1025            new_pos = new_pos.saturating_sub(1);
1026        }
1027
1028        self.cursor_pos = new_pos;
1029    }
1030
1031    /// Move to start of next word (without selection)
1032    /// Mimics Buffer's find_word_start_right behavior
1033    pub fn move_word_right(&mut self) {
1034        self.clear_selection();
1035
1036        let bytes = self.input.as_bytes();
1037        if self.cursor_pos >= bytes.len() {
1038            return;
1039        }
1040
1041        let mut new_pos = self.cursor_pos;
1042
1043        // Skip current word
1044        while new_pos < bytes.len() && is_word_char(bytes[new_pos]) {
1045            new_pos += 1;
1046        }
1047
1048        // Skip non-word characters (spaces)
1049        while new_pos < bytes.len() && !is_word_char(bytes[new_pos]) {
1050            new_pos += 1;
1051        }
1052
1053        self.cursor_pos = new_pos;
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    #[test]
1062    fn test_delete_word_forward_basic() {
1063        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1064        prompt.input = "hello world test".to_string();
1065        prompt.cursor_pos = 0;
1066
1067        prompt.delete_word_forward();
1068        assert_eq!(prompt.input, " world test");
1069        assert_eq!(prompt.cursor_pos, 0);
1070    }
1071
1072    #[test]
1073    fn test_delete_word_forward_middle() {
1074        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1075        prompt.input = "hello world test".to_string();
1076        prompt.cursor_pos = 3; // Middle of "hello"
1077
1078        prompt.delete_word_forward();
1079        assert_eq!(prompt.input, "hel world test");
1080        assert_eq!(prompt.cursor_pos, 3);
1081    }
1082
1083    #[test]
1084    fn test_delete_word_forward_at_space() {
1085        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1086        prompt.input = "hello world".to_string();
1087        prompt.cursor_pos = 5; // At space after "hello"
1088
1089        prompt.delete_word_forward();
1090        assert_eq!(prompt.input, "hello");
1091        assert_eq!(prompt.cursor_pos, 5);
1092    }
1093
1094    #[test]
1095    fn test_delete_word_backward_basic() {
1096        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1097        prompt.input = "hello world test".to_string();
1098        prompt.cursor_pos = 5; // After "hello"
1099
1100        prompt.delete_word_backward();
1101        assert_eq!(prompt.input, " world test");
1102        assert_eq!(prompt.cursor_pos, 0);
1103    }
1104
1105    #[test]
1106    fn test_delete_word_backward_middle() {
1107        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1108        prompt.input = "hello world test".to_string();
1109        prompt.cursor_pos = 8; // Middle of "world"
1110
1111        prompt.delete_word_backward();
1112        assert_eq!(prompt.input, "hello rld test");
1113        assert_eq!(prompt.cursor_pos, 6);
1114    }
1115
1116    #[test]
1117    fn test_delete_word_backward_at_end() {
1118        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1119        prompt.input = "hello world".to_string();
1120        prompt.cursor_pos = 11; // At end
1121
1122        prompt.delete_word_backward();
1123        assert_eq!(prompt.input, "hello ");
1124        assert_eq!(prompt.cursor_pos, 6);
1125    }
1126
1127    #[test]
1128    fn test_delete_word_with_special_chars() {
1129        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1130        prompt.input = "save-file-as".to_string();
1131        prompt.cursor_pos = 12; // At end
1132
1133        // Delete "as"
1134        prompt.delete_word_backward();
1135        assert_eq!(prompt.input, "save-file-");
1136        assert_eq!(prompt.cursor_pos, 10);
1137
1138        // Delete "file"
1139        prompt.delete_word_backward();
1140        assert_eq!(prompt.input, "save-");
1141        assert_eq!(prompt.cursor_pos, 5);
1142    }
1143
1144    #[test]
1145    fn test_get_text() {
1146        let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
1147        prompt.input = "test content".to_string();
1148
1149        assert_eq!(prompt.get_text(), "test content");
1150    }
1151
1152    #[test]
1153    fn test_clear() {
1154        let mut prompt = Prompt::new("Find: ".to_string(), PromptType::OpenFile);
1155        prompt.input = "some text".to_string();
1156        prompt.cursor_pos = 5;
1157        prompt.selected_suggestion = Some(0);
1158
1159        prompt.clear();
1160
1161        assert_eq!(prompt.input, "");
1162        assert_eq!(prompt.cursor_pos, 0);
1163        assert_eq!(prompt.selected_suggestion, None);
1164    }
1165
1166    #[test]
1167    fn test_delete_forward_basic() {
1168        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1169        prompt.input = "hello".to_string();
1170        prompt.cursor_pos = 1; // After 'h'
1171
1172        // Simulate delete key (remove 'e')
1173        prompt.input.drain(prompt.cursor_pos..prompt.cursor_pos + 1);
1174
1175        assert_eq!(prompt.input, "hllo");
1176        assert_eq!(prompt.cursor_pos, 1);
1177    }
1178
1179    #[test]
1180    fn test_delete_at_end() {
1181        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1182        prompt.input = "hello".to_string();
1183        prompt.cursor_pos = 5; // At end
1184
1185        // Delete at end should do nothing
1186        if prompt.cursor_pos < prompt.input.len() {
1187            prompt.input.drain(prompt.cursor_pos..prompt.cursor_pos + 1);
1188        }
1189
1190        assert_eq!(prompt.input, "hello");
1191        assert_eq!(prompt.cursor_pos, 5);
1192    }
1193
1194    #[test]
1195    fn test_insert_str_at_start() {
1196        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1197        prompt.input = "world".to_string();
1198        prompt.cursor_pos = 0;
1199
1200        prompt.insert_str("hello ");
1201        assert_eq!(prompt.input, "hello world");
1202        assert_eq!(prompt.cursor_pos, 6);
1203    }
1204
1205    #[test]
1206    fn test_insert_str_at_middle() {
1207        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1208        prompt.input = "helloworld".to_string();
1209        prompt.cursor_pos = 5;
1210
1211        prompt.insert_str(" ");
1212        assert_eq!(prompt.input, "hello world");
1213        assert_eq!(prompt.cursor_pos, 6);
1214    }
1215
1216    #[test]
1217    fn test_insert_str_at_end() {
1218        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1219        prompt.input = "hello".to_string();
1220        prompt.cursor_pos = 5;
1221
1222        prompt.insert_str(" world");
1223        assert_eq!(prompt.input, "hello world");
1224        assert_eq!(prompt.cursor_pos, 11);
1225    }
1226
1227    #[test]
1228    fn test_delete_word_forward_empty() {
1229        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1230        prompt.input = "".to_string();
1231        prompt.cursor_pos = 0;
1232
1233        prompt.delete_word_forward();
1234        assert_eq!(prompt.input, "");
1235        assert_eq!(prompt.cursor_pos, 0);
1236    }
1237
1238    #[test]
1239    fn test_delete_word_backward_empty() {
1240        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1241        prompt.input = "".to_string();
1242        prompt.cursor_pos = 0;
1243
1244        prompt.delete_word_backward();
1245        assert_eq!(prompt.input, "");
1246        assert_eq!(prompt.cursor_pos, 0);
1247    }
1248
1249    #[test]
1250    fn test_delete_word_forward_only_spaces() {
1251        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1252        prompt.input = "   ".to_string();
1253        prompt.cursor_pos = 0;
1254
1255        prompt.delete_word_forward();
1256        assert_eq!(prompt.input, "");
1257        assert_eq!(prompt.cursor_pos, 0);
1258    }
1259
1260    #[test]
1261    fn test_multiple_word_deletions() {
1262        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1263        prompt.input = "one two three four".to_string();
1264        prompt.cursor_pos = 18;
1265
1266        prompt.delete_word_backward(); // Delete "four"
1267        assert_eq!(prompt.input, "one two three ");
1268
1269        prompt.delete_word_backward(); // Delete "three"
1270        assert_eq!(prompt.input, "one two ");
1271
1272        prompt.delete_word_backward(); // Delete "two"
1273        assert_eq!(prompt.input, "one ");
1274    }
1275
1276    // Tests for selection functionality
1277    #[test]
1278    fn test_selection_with_shift_arrows() {
1279        let mut prompt = Prompt::new("Command: ".to_string(), PromptType::QuickOpen);
1280        prompt.input = "hello world".to_string();
1281        prompt.cursor_pos = 5; // After "hello"
1282
1283        // No selection initially
1284        assert!(!prompt.has_selection());
1285        assert_eq!(prompt.selected_text(), None);
1286
1287        // Move right selecting - should select " "
1288        prompt.move_right_selecting();
1289        assert!(prompt.has_selection());
1290        assert_eq!(prompt.selection_range(), Some((5, 6)));
1291        assert_eq!(prompt.selected_text(), Some(" ".to_string()));
1292
1293        // Move right selecting again - should select " w"
1294        prompt.move_right_selecting();
1295        assert_eq!(prompt.selection_range(), Some((5, 7)));
1296        assert_eq!(prompt.selected_text(), Some(" w".to_string()));
1297
1298        // Move left selecting - should shrink to " "
1299        prompt.move_left_selecting();
1300        assert_eq!(prompt.selection_range(), Some((5, 6)));
1301        assert_eq!(prompt.selected_text(), Some(" ".to_string()));
1302    }
1303
1304    #[test]
1305    fn test_selection_backward() {
1306        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1307        prompt.input = "abcdef".to_string();
1308        prompt.cursor_pos = 4; // After "abcd"
1309
1310        // Select backward
1311        prompt.move_left_selecting();
1312        prompt.move_left_selecting();
1313        assert!(prompt.has_selection());
1314        assert_eq!(prompt.selection_range(), Some((2, 4)));
1315        assert_eq!(prompt.selected_text(), Some("cd".to_string()));
1316    }
1317
1318    #[test]
1319    fn test_selection_with_home_end() {
1320        let mut prompt = Prompt::new("Prompt: ".to_string(), PromptType::QuickOpen);
1321        prompt.input = "select this text".to_string();
1322        prompt.cursor_pos = 7; // After "select "
1323
1324        // Select to end
1325        prompt.move_end_selecting();
1326        assert_eq!(prompt.selection_range(), Some((7, 16)));
1327        assert_eq!(prompt.selected_text(), Some("this text".to_string()));
1328
1329        // Clear and select from current position to home
1330        prompt.clear_selection();
1331        prompt.move_home_selecting();
1332        assert_eq!(prompt.selection_range(), Some((0, 16)));
1333        assert_eq!(prompt.selected_text(), Some("select this text".to_string()));
1334    }
1335
1336    #[test]
1337    fn test_word_selection() {
1338        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1339        prompt.input = "one two three".to_string();
1340        prompt.cursor_pos = 4; // After "one "
1341
1342        // Select word right
1343        prompt.move_word_right_selecting();
1344        assert_eq!(prompt.selection_range(), Some((4, 7)));
1345        assert_eq!(prompt.selected_text(), Some("two".to_string()));
1346
1347        // Select another word
1348        prompt.move_word_right_selecting();
1349        assert_eq!(prompt.selection_range(), Some((4, 13)));
1350        assert_eq!(prompt.selected_text(), Some("two three".to_string()));
1351    }
1352
1353    #[test]
1354    fn test_word_selection_backward() {
1355        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1356        prompt.input = "one two three".to_string();
1357        prompt.cursor_pos = 13; // At end
1358
1359        // Select word left - moves to start of "three"
1360        prompt.move_word_left_selecting();
1361        assert_eq!(prompt.selection_range(), Some((8, 13)));
1362        assert_eq!(prompt.selected_text(), Some("three".to_string()));
1363
1364        // Note: Currently, calling move_word_left_selecting again when already
1365        // at a word boundary doesn't move further back. This matches the behavior
1366        // of find_word_start_bytes which finds the start of the current word.
1367        // For multi-word backward selection, move cursor backward first, then select.
1368    }
1369
1370    #[test]
1371    fn test_delete_selection() {
1372        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1373        prompt.input = "hello world".to_string();
1374        prompt.cursor_pos = 5;
1375
1376        // Select " world"
1377        prompt.move_end_selecting();
1378        assert_eq!(prompt.selected_text(), Some(" world".to_string()));
1379
1380        // Delete selection
1381        let deleted = prompt.delete_selection();
1382        assert_eq!(deleted, Some(" world".to_string()));
1383        assert_eq!(prompt.input, "hello");
1384        assert_eq!(prompt.cursor_pos, 5);
1385        assert!(!prompt.has_selection());
1386    }
1387
1388    #[test]
1389    fn test_insert_deletes_selection() {
1390        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1391        prompt.input = "hello world".to_string();
1392        prompt.cursor_pos = 0;
1393
1394        // Select "hello"
1395        for _ in 0..5 {
1396            prompt.move_right_selecting();
1397        }
1398        assert_eq!(prompt.selected_text(), Some("hello".to_string()));
1399
1400        // Insert text - should delete selection first
1401        prompt.insert_str("goodbye");
1402        assert_eq!(prompt.input, "goodbye world");
1403        assert_eq!(prompt.cursor_pos, 7);
1404        assert!(!prompt.has_selection());
1405    }
1406
1407    #[test]
1408    fn test_clear_selection() {
1409        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1410        prompt.input = "test".to_string();
1411        prompt.cursor_pos = 0;
1412
1413        // Create selection
1414        prompt.move_end_selecting();
1415        assert!(prompt.has_selection());
1416
1417        // Clear selection
1418        prompt.clear_selection();
1419        assert!(!prompt.has_selection());
1420        assert_eq!(prompt.cursor_pos, 4); // Cursor should remain at end
1421        assert_eq!(prompt.input, "test"); // Input unchanged
1422    }
1423
1424    #[test]
1425    fn test_selection_edge_cases() {
1426        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1427        prompt.input = "abc".to_string();
1428        prompt.cursor_pos = 3;
1429
1430        // Select beyond end should stop at end (no movement, no selection)
1431        prompt.move_right_selecting();
1432        assert_eq!(prompt.cursor_pos, 3);
1433        // Since cursor didn't move, anchor equals cursor, so no selection
1434        assert_eq!(prompt.selection_range(), None);
1435        assert_eq!(prompt.selected_text(), None);
1436
1437        // Delete non-existent selection should return None
1438        assert_eq!(prompt.delete_selection(), None);
1439        assert_eq!(prompt.input, "abc");
1440    }
1441
1442    #[test]
1443    fn test_selection_with_unicode() {
1444        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1445        prompt.input = "hello 世界 world".to_string();
1446        prompt.cursor_pos = 6; // After "hello "
1447
1448        // Select the Chinese characters
1449        for _ in 0..2 {
1450            prompt.move_right_selecting();
1451        }
1452
1453        let selected = prompt.selected_text().unwrap();
1454        assert_eq!(selected, "世界");
1455
1456        // Delete should work correctly
1457        prompt.delete_selection();
1458        assert_eq!(prompt.input, "hello  world");
1459    }
1460
1461    // BUG REPRODUCTION TESTS
1462
1463    /// Test that Ctrl+Shift+Left continues past first word boundary (was bug #2)
1464    #[test]
1465    fn test_word_selection_continues_across_words() {
1466        let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1467        prompt.input = "one two three".to_string();
1468        prompt.cursor_pos = 13; // At end
1469
1470        // First Ctrl+Shift+Left - selects "three"
1471        prompt.move_word_left_selecting();
1472        assert_eq!(prompt.selection_range(), Some((8, 13)));
1473        assert_eq!(prompt.selected_text(), Some("three".to_string()));
1474
1475        // Second Ctrl+Shift+Left - should extend to "two three"
1476        // Now correctly moves back one more word when already at word boundary
1477        prompt.move_word_left_selecting();
1478
1479        // Selection should extend to include "two three"
1480        assert_eq!(prompt.selection_range(), Some((4, 13)));
1481        assert_eq!(prompt.selected_text(), Some("two three".to_string()));
1482    }
1483
1484    // Property-based tests for Prompt operations
1485    #[cfg(test)]
1486    mod property_tests {
1487        use super::*;
1488        use proptest::prelude::*;
1489
1490        proptest! {
1491            /// Property: delete_word_backward should never increase input length
1492            #[test]
1493            fn prop_delete_word_backward_shrinks(
1494                input in "[a-zA-Z0-9_ ]{0,50}",
1495                cursor_pos in 0usize..50
1496            ) {
1497                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1498                prompt.input = input.clone();
1499                prompt.cursor_pos = cursor_pos.min(input.len());
1500
1501                let original_len = prompt.input.len();
1502                prompt.delete_word_backward();
1503
1504                prop_assert!(prompt.input.len() <= original_len);
1505            }
1506
1507            /// Property: delete_word_forward should never increase input length
1508            #[test]
1509            fn prop_delete_word_forward_shrinks(
1510                input in "[a-zA-Z0-9_ ]{0,50}",
1511                cursor_pos in 0usize..50
1512            ) {
1513                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1514                prompt.input = input.clone();
1515                prompt.cursor_pos = cursor_pos.min(input.len());
1516
1517                let original_len = prompt.input.len();
1518                prompt.delete_word_forward();
1519
1520                prop_assert!(prompt.input.len() <= original_len);
1521            }
1522
1523            /// Property: delete_word_backward should not move cursor past input start
1524            #[test]
1525            fn prop_delete_word_backward_cursor_valid(
1526                input in "[a-zA-Z0-9_ ]{0,50}",
1527                cursor_pos in 0usize..50
1528            ) {
1529                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1530                prompt.input = input.clone();
1531                prompt.cursor_pos = cursor_pos.min(input.len());
1532
1533                prompt.delete_word_backward();
1534
1535                prop_assert!(prompt.cursor_pos <= prompt.input.len());
1536            }
1537
1538            /// Property: delete_word_forward should keep cursor in valid range
1539            #[test]
1540            fn prop_delete_word_forward_cursor_valid(
1541                input in "[a-zA-Z0-9_ ]{0,50}",
1542                cursor_pos in 0usize..50
1543            ) {
1544                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1545                prompt.input = input.clone();
1546                prompt.cursor_pos = cursor_pos.min(input.len());
1547
1548                prompt.delete_word_forward();
1549
1550                prop_assert!(prompt.cursor_pos <= prompt.input.len());
1551            }
1552
1553            /// Property: insert_str should increase length by inserted text length
1554            #[test]
1555            fn prop_insert_str_length(
1556                input in "[a-zA-Z0-9_ ]{0,30}",
1557                insert in "[a-zA-Z0-9_ ]{0,20}",
1558                cursor_pos in 0usize..30
1559            ) {
1560                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1561                prompt.input = input.clone();
1562                prompt.cursor_pos = cursor_pos.min(input.len());
1563
1564                let original_len = prompt.input.len();
1565                prompt.insert_str(&insert);
1566
1567                prop_assert_eq!(prompt.input.len(), original_len + insert.len());
1568            }
1569
1570            /// Property: insert_str should move cursor by inserted text length
1571            #[test]
1572            fn prop_insert_str_cursor(
1573                input in "[a-zA-Z0-9_ ]{0,30}",
1574                insert in "[a-zA-Z0-9_ ]{0,20}",
1575                cursor_pos in 0usize..30
1576            ) {
1577                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1578                prompt.input = input.clone();
1579                let original_pos = cursor_pos.min(input.len());
1580                prompt.cursor_pos = original_pos;
1581
1582                prompt.insert_str(&insert);
1583
1584                prop_assert_eq!(prompt.cursor_pos, original_pos + insert.len());
1585            }
1586
1587            /// Property: clear should always result in empty string and zero cursor
1588            #[test]
1589            fn prop_clear_resets(input in "[a-zA-Z0-9_ ]{0,50}") {
1590                let mut prompt = Prompt::new("Test: ".to_string(), PromptType::Search);
1591                prompt.input = input;
1592                prompt.cursor_pos = prompt.input.len();
1593
1594                prompt.clear();
1595
1596                prop_assert_eq!(prompt.input, "");
1597                prop_assert_eq!(prompt.cursor_pos, 0);
1598            }
1599        }
1600    }
1601}