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