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