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