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