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