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