Skip to main content

twrite_core/
prompt.rs

1use crate::KeyCode;
2use crate::hook::KeyEvent;
3
4/// Where a frontend should render an open prompt.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum PromptPlacement {
7    /// Vim-style single line anchored to the bottom (`:`, `/`, `Ctrl+F`).
8    #[default]
9    BottomBar,
10    /// Browser-`F1` style floating palette centered near the top.
11    TopPalette,
12}
13
14/// One selectable row in a palette-style prompt (commands, files, matches).
15///
16/// Hooks own the meaning: they fill these, the frontend only draws them.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PromptItem {
19    /// The primary row text (inserted on Tab-completion).
20    pub label: String,
21    /// Secondary hint text (keybinding, path, match count).
22    pub hint: Option<String>,
23}
24
25impl PromptItem {
26    /// Creates an item with no hint.
27    pub fn new(label: &str) -> Self {
28        Self {
29            label: label.to_string(),
30            hint: None,
31        }
32    }
33
34    /// Creates an item with hint text.
35    pub fn with_hint(label: &str, hint: &str) -> Self {
36        Self {
37            label: label.to_string(),
38            hint: Some(hint.to_string()),
39        }
40    }
41}
42
43/// How a prompt was opened. Semantics belong to hooks; this is only the
44/// view-model a frontend renders.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PromptSpec {
47    /// Stable id so hooks recognize their own prompt (`"search"`, `"vim-ex"`).
48    pub id: &'static str,
49    /// Leading adornment (`"/"`, `":"`, `""`).
50    pub prefix: String,
51    /// Ghost text shown while the input is empty.
52    pub placeholder: String,
53    /// Where the frontend should anchor the box.
54    pub placement: PromptPlacement,
55    /// Hint that the hook refreshes live on every keystroke (search) rather
56    /// than only on submit (ex-commands).
57    pub live_update: bool,
58}
59
60impl PromptSpec {
61    /// Creates a prompt spec.
62    pub fn new(
63        id: &'static str,
64        prefix: &str,
65        placeholder: &str,
66        placement: PromptPlacement,
67        live_update: bool,
68    ) -> Self {
69        Self {
70            id,
71            prefix: prefix.to_string(),
72            placeholder: placeholder.to_string(),
73            placement,
74            live_update,
75        }
76    }
77}
78
79/// What [`PromptState::handle_key`] decided for a keystroke.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PromptAction {
82    /// Consumed as prompt editing; the prompt stays open.
83    Editing,
84    /// `Enter`: the input was submitted (history recorded, prompt left open
85    /// for the caller to inspect items/message and close explicitly).
86    Submitted(String),
87    /// `Escape`: the prompt was closed.
88    Cancelled,
89    /// Not a prompt key (closed prompt, or e.g. `Tab` with no items):
90    /// the caller may pass it through to buffer handling.
91    Ignored,
92}
93
94/// Maximum retained prompt history entries.
95const HISTORY_LIMIT: usize = 100;
96
97/// Headless single-line input + item list + history.
98///
99/// Owned by the host (the GPUI `Editor`, or a test harness) and shared with
100/// hooks via `HookContext::prompt`, so any hook can open a bottom bar or
101/// command palette with zero frontend code: `ctx.prompt.open(...)`, read
102/// `ctx.prompt.input()` on submit, push a [`crate::HookEffect`] for
103/// app-level work (save/quit). Frontends only render + forward keys.
104#[derive(Debug, Clone, Default)]
105pub struct PromptState {
106    spec: Option<PromptSpec>,
107    input: String,
108    cursor: usize,
109    items: Vec<PromptItem>,
110    selected: usize,
111    message: Option<String>,
112    history: Vec<String>,
113    history_idx: Option<usize>,
114    draft: String,
115}
116
117impl PromptState {
118    /// Creates a closed prompt.
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Whether a prompt is currently open.
124    pub fn is_open(&self) -> bool {
125        self.spec.is_some()
126    }
127
128    /// Returns the active spec, if open.
129    pub fn spec(&self) -> Option<&PromptSpec> {
130        self.spec.as_ref()
131    }
132
133    /// Current input text.
134    pub fn input(&self) -> &str {
135        &self.input
136    }
137
138    /// Byte index of the input cursor (always a char boundary).
139    pub fn cursor(&self) -> usize {
140        self.cursor
141    }
142
143    /// Current item rows.
144    pub fn items(&self) -> &[PromptItem] {
145        &self.items
146    }
147
148    /// Selected item index.
149    pub fn selected_index(&self) -> usize {
150        self.selected
151    }
152
153    /// Selected item, if the list is non-empty.
154    pub fn selected_item(&self) -> Option<&PromptItem> {
155        self.items.get(self.selected)
156    }
157
158    /// Validation / error message to show under the box.
159    pub fn message(&self) -> Option<&str> {
160        self.message.as_deref()
161    }
162
163    /// Submitted-input history, oldest first.
164    pub fn history(&self) -> &[String] {
165        &self.history
166    }
167
168    /// Opens a prompt, replacing any active one.
169    pub fn open(&mut self, spec: PromptSpec, initial: &str) {
170        self.spec = Some(spec);
171        self.input = initial.to_string();
172        self.cursor = self.input.len();
173        self.items.clear();
174        self.selected = 0;
175        self.message = None;
176        self.history_idx = None;
177        self.draft.clear();
178    }
179
180    /// Closes the prompt, clearing input, items, and message (history kept).
181    pub fn close(&mut self) {
182        self.spec = None;
183        self.input.clear();
184        self.cursor = 0;
185        self.items.clear();
186        self.selected = 0;
187        self.message = None;
188        self.history_idx = None;
189        self.draft.clear();
190    }
191
192    /// Sets the validation / error message.
193    pub fn set_message(&mut self, message: &str) {
194        self.message = Some(message.to_string());
195    }
196
197    /// Clears the validation / error message.
198    pub fn clear_message(&mut self) {
199        self.message = None;
200    }
201
202    /// Replaces the item list, clamping the selection into range.
203    pub fn set_items(&mut self, items: Vec<PromptItem>) {
204        self.items = items;
205        self.selected = self.selected.min(self.items.len().saturating_sub(1));
206    }
207
208    /// Previous char boundary at or before `idx`.
209    fn prev_boundary(&self, idx: usize) -> usize {
210        let mut pos = idx.min(self.input.len());
211        if pos == 0 {
212            return 0;
213        }
214        pos -= 1;
215        while pos > 0 && !self.input.is_char_boundary(pos) {
216            pos -= 1;
217        }
218        pos
219    }
220
221    /// Next char boundary at or after `idx`.
222    fn next_boundary(&self, idx: usize) -> usize {
223        let mut pos = idx.min(self.input.len());
224        if pos >= self.input.len() {
225            return self.input.len();
226        }
227        pos += 1;
228        while pos < self.input.len() && !self.input.is_char_boundary(pos) {
229            pos += 1;
230        }
231        pos
232    }
233
234    /// Editing abandons history browsing.
235    fn abandon_history(&mut self) {
236        self.history_idx = None;
237        self.draft.clear();
238    }
239
240    /// Removes the char ending at the cursor (cursor must be a boundary).
241    fn backspace_one(&mut self) {
242        if self.cursor == 0 {
243            return;
244        }
245        let prev = self.prev_boundary(self.cursor);
246        self.input.drain(prev..self.cursor);
247        self.cursor = prev;
248    }
249
250    /// Inserts text at the input cursor.
251    pub fn insert(&mut self, text: &str) {
252        self.abandon_history();
253        self.input.insert_str(self.cursor, text);
254        self.cursor += text.len();
255    }
256
257    /// Deletes the char before the input cursor (UTF-8 safe).
258    pub fn backspace(&mut self) {
259        self.abandon_history();
260        self.backspace_one();
261    }
262
263    /// Deletes the char after the input cursor (UTF-8 safe).
264    pub fn delete_after_cursor(&mut self) {
265        self.abandon_history();
266        if self.cursor < self.input.len() {
267            let next = self.next_boundary(self.cursor);
268            self.input.drain(self.cursor..next);
269        }
270    }
271
272    /// Deletes back to the previous blank-separated word start (`Ctrl+W`, `Ctrl+Backspace`).
273    pub fn delete_word_before(&mut self) {
274        self.abandon_history();
275        while self.cursor > 0
276            && self.input[..self.cursor]
277                .chars()
278                .next_back()
279                .is_some_and(|c| c.is_whitespace())
280        {
281            self.backspace_one();
282        }
283        while self.cursor > 0
284            && self.input[..self.cursor]
285                .chars()
286                .next_back()
287                .is_some_and(|c| !c.is_whitespace())
288        {
289            self.backspace_one();
290        }
291    }
292
293    /// Deletes from the cursor forward across the next word (`Ctrl+Delete`).
294    pub fn delete_word_after(&mut self) {
295        self.abandon_history();
296        while self.cursor < self.input.len()
297            && self.input[self.cursor..]
298                .chars()
299                .next()
300                .is_some_and(|c| c.is_whitespace())
301        {
302            self.delete_after_cursor();
303        }
304        while self.cursor < self.input.len()
305            && self.input[self.cursor..]
306                .chars()
307                .next()
308                .is_some_and(|c| !c.is_whitespace())
309        {
310            self.delete_after_cursor();
311        }
312    }
313
314    /// Clears everything before the cursor (`Ctrl+U`).
315    pub fn clear_to_start(&mut self) {
316        self.abandon_history();
317        self.input.drain(..self.cursor);
318        self.cursor = 0;
319    }
320
321    /// Clears everything after the cursor (`Ctrl+K`).
322    pub fn clear_to_end(&mut self) {
323        self.abandon_history();
324        self.input.truncate(self.cursor);
325    }
326
327    /// Moves the input cursor to the start of the previous word (`Ctrl+Left`).
328    pub fn move_word_left(&mut self) {
329        while self.cursor > 0
330            && self.input[..self.cursor]
331                .chars()
332                .next_back()
333                .is_some_and(|c| c.is_whitespace())
334        {
335            self.cursor = self.prev_boundary(self.cursor);
336        }
337        while self.cursor > 0
338            && self.input[..self.cursor]
339                .chars()
340                .next_back()
341                .is_some_and(|c| !c.is_whitespace())
342        {
343            self.cursor = self.prev_boundary(self.cursor);
344        }
345    }
346
347    /// Moves the input cursor to the end of the current or next word (`Ctrl+Right`).
348    pub fn move_word_right(&mut self) {
349        while self.cursor < self.input.len()
350            && self.input[self.cursor..]
351                .chars()
352                .next()
353                .is_some_and(|c| c.is_whitespace())
354        {
355            self.cursor = self.next_boundary(self.cursor);
356        }
357        while self.cursor < self.input.len()
358            && self.input[self.cursor..]
359                .chars()
360                .next()
361                .is_some_and(|c| !c.is_whitespace())
362        {
363            self.cursor = self.next_boundary(self.cursor);
364        }
365    }
366
367    /// Moves the input cursor one char left.
368    pub fn move_left(&mut self) {
369        self.cursor = self.prev_boundary(self.cursor);
370    }
371
372    /// Moves the input cursor one char right.
373    pub fn move_right(&mut self) {
374        self.cursor = self.next_boundary(self.cursor);
375    }
376
377    /// Moves the input cursor to the start.
378    pub fn move_home(&mut self) {
379        self.cursor = 0;
380    }
381
382    /// Moves the input cursor to the end.
383    pub fn move_end(&mut self) {
384        self.cursor = self.input.len();
385    }
386
387    /// Steps back through submitted-input history.
388    pub fn history_prev(&mut self) {
389        if self.history.is_empty() {
390            return;
391        }
392        let idx = match self.history_idx {
393            None => {
394                self.draft = self.input.clone();
395                self.history.len() - 1
396            }
397            Some(0) => return,
398            Some(i) => i - 1,
399        };
400        self.history_idx = Some(idx);
401        self.input = self.history[idx].clone();
402        self.cursor = self.input.len();
403    }
404
405    /// Steps forward through history, restoring the draft at the end.
406    pub fn history_next(&mut self) {
407        let idx = match self.history_idx {
408            None => return,
409            Some(i) => i,
410        };
411        if idx + 1 < self.history.len() {
412            self.history_idx = Some(idx + 1);
413            self.input = self.history[idx + 1].clone();
414        } else {
415            self.history_idx = None;
416            self.input = std::mem::take(&mut self.draft);
417        }
418        self.cursor = self.input.len();
419    }
420
421    /// Moves the item selection forward, wrapping.
422    pub fn select_next(&mut self) {
423        if self.items.is_empty() {
424            return;
425        }
426        self.selected = (self.selected + 1) % self.items.len();
427    }
428
429    /// Moves the item selection backward, wrapping.
430    pub fn select_prev(&mut self) {
431        if self.items.is_empty() {
432            return;
433        }
434        self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1);
435    }
436
437    /// Copies the selected item's label into the input (`Tab`).
438    pub fn complete_selected(&mut self) {
439        if let Some(item) = self.selected_item() {
440            let label = item.label.clone();
441            self.abandon_history();
442            self.input = label;
443            self.cursor = self.input.len();
444        }
445    }
446
447    /// Records the input in history and returns it. Leaves the prompt open
448    /// so the caller can inspect items/message before closing.
449    pub fn submit(&mut self) -> String {
450        let submitted = self.input.clone();
451        if !submitted.is_empty() && self.history.last() != Some(&submitted) {
452            self.history.push(submitted.clone());
453            if self.history.len() > HISTORY_LIMIT {
454                let excess = self.history.len() - HISTORY_LIMIT;
455                self.history.drain(..excess);
456            }
457        }
458        self.history_idx = None;
459        self.draft.clear();
460        submitted
461    }
462
463    /// Routes one keystroke when the prompt is open. Returns [`PromptAction::Ignored`]
464    /// when closed (or for keys the prompt does not own, e.g. `Tab` with an
465    /// empty item list) so callers can fall through to buffer handling.
466    pub fn handle_key(&mut self, event: &KeyEvent) -> PromptAction {
467        if self.spec.is_none() {
468            return PromptAction::Ignored;
469        }
470        let mods = &event.modifiers;
471        match &event.code {
472            KeyCode::Escape => {
473                self.close();
474                PromptAction::Cancelled
475            }
476            KeyCode::Enter if !mods.ctrl && !mods.alt && !mods.meta => {
477                PromptAction::Submitted(self.submit())
478            }
479            KeyCode::Tab => {
480                if self.selected_item().is_some() {
481                    self.complete_selected();
482                    PromptAction::Editing
483                } else {
484                    PromptAction::Ignored
485                }
486            }
487            KeyCode::Up if !mods.ctrl && !mods.alt && !mods.meta => {
488                if self.items.is_empty() {
489                    self.history_prev();
490                } else {
491                    self.select_prev();
492                }
493                PromptAction::Editing
494            }
495            KeyCode::Down if !mods.ctrl && !mods.alt && !mods.meta => {
496                if self.items.is_empty() {
497                    self.history_next();
498                } else {
499                    self.select_next();
500                }
501                PromptAction::Editing
502            }
503            KeyCode::Backspace if (mods.ctrl || mods.alt) && !mods.meta => {
504                self.delete_word_before();
505                PromptAction::Editing
506            }
507            KeyCode::Backspace if mods.meta && !mods.ctrl && !mods.alt => {
508                self.clear_to_start();
509                PromptAction::Editing
510            }
511            KeyCode::Backspace if !mods.ctrl && !mods.alt && !mods.meta => {
512                self.backspace();
513                PromptAction::Editing
514            }
515            KeyCode::Delete if (mods.ctrl || mods.alt) && !mods.meta => {
516                self.delete_word_after();
517                PromptAction::Editing
518            }
519            KeyCode::Delete if !mods.ctrl && !mods.alt && !mods.meta => {
520                self.delete_after_cursor();
521                PromptAction::Editing
522            }
523            KeyCode::Left if (mods.ctrl || mods.alt) && !mods.meta => {
524                self.move_word_left();
525                PromptAction::Editing
526            }
527            KeyCode::Left if mods.meta && !mods.ctrl && !mods.alt => {
528                self.move_home();
529                PromptAction::Editing
530            }
531            KeyCode::Left if !mods.ctrl && !mods.alt && !mods.meta => {
532                self.move_left();
533                PromptAction::Editing
534            }
535            KeyCode::Right if (mods.ctrl || mods.alt) && !mods.meta => {
536                self.move_word_right();
537                PromptAction::Editing
538            }
539            KeyCode::Right if mods.meta && !mods.ctrl && !mods.alt => {
540                self.move_end();
541                PromptAction::Editing
542            }
543            KeyCode::Right if !mods.ctrl && !mods.alt && !mods.meta => {
544                self.move_right();
545                PromptAction::Editing
546            }
547            KeyCode::Home => {
548                self.move_home();
549                PromptAction::Editing
550            }
551            KeyCode::End => {
552                self.move_end();
553                PromptAction::Editing
554            }
555            KeyCode::Char(c) if mods.ctrl && !mods.alt && !mods.meta => {
556                match c.to_ascii_lowercase() {
557                    'u' => {
558                        self.clear_to_start();
559                        PromptAction::Editing
560                    }
561                    'k' => {
562                        self.clear_to_end();
563                        PromptAction::Editing
564                    }
565                    'w' => {
566                        self.delete_word_before();
567                        PromptAction::Editing
568                    }
569                    'a' => {
570                        self.move_home();
571                        PromptAction::Editing
572                    }
573                    'e' => {
574                        self.move_end();
575                        PromptAction::Editing
576                    }
577                    _ => PromptAction::Ignored,
578                }
579            }
580            KeyCode::Char(c) if !mods.ctrl && !mods.alt && !mods.meta => {
581                self.insert(&c.to_string());
582                PromptAction::Editing
583            }
584            _ => PromptAction::Ignored,
585        }
586    }
587}
588
589/// Scores `candidate` against `query` as a case-insensitive subsequence.
590///
591/// Higher is better; `None` when the query is not a subsequence. Empty
592/// queries match everything with score `0` (palette shows all rows).
593pub fn fuzzy_score(candidate: &str, query: &str) -> Option<i64> {
594    if query.is_empty() {
595        return Some(0);
596    }
597    let lowered: Vec<char> = candidate.to_lowercase().chars().collect();
598    let wanted: Vec<char> = query.to_lowercase().chars().collect();
599    let mut score: i64 = 0;
600    let mut pos = 0;
601    let mut prev: Option<usize> = None;
602    for (qi, qc) in wanted.iter().enumerate() {
603        let mut found = None;
604        for (ci, cc) in lowered.iter().enumerate().skip(pos) {
605            if cc == qc {
606                found = Some(ci);
607                break;
608            }
609        }
610        let ci = found?;
611        score += 10;
612        if qi == 0 && ci == 0 {
613            score += 20;
614        }
615        if prev.is_some_and(|p| p + 1 == ci) {
616            score += 15;
617        }
618        prev = Some(ci);
619        pos = ci + 1;
620    }
621    Some(score - lowered.len() as i64)
622}
623
624/// Filters item labels against `query`, returning `(index, score)` pairs
625/// sorted by score descending, index ascending.
626pub fn fuzzy_filter(candidates: &[PromptItem], query: &str) -> Vec<(usize, i64)> {
627    let mut ranked: Vec<(usize, i64)> = candidates
628        .iter()
629        .enumerate()
630        .filter_map(|(i, item)| fuzzy_score(&item.label, query).map(|s| (i, s)))
631        .collect();
632    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
633    ranked
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::hook::Modifiers;
640    use crate::keycode::KeyCode;
641
642    fn search_spec() -> PromptSpec {
643        PromptSpec::new("search", "/", "Search", PromptPlacement::BottomBar, true)
644    }
645
646    fn palette_spec() -> PromptSpec {
647        PromptSpec::new(
648            "commands",
649            "",
650            "Type a command",
651            PromptPlacement::TopPalette,
652            false,
653        )
654    }
655
656    fn key(code: KeyCode) -> KeyEvent {
657        KeyEvent::plain(code)
658    }
659
660    fn ctrl(code: KeyCode) -> KeyEvent {
661        KeyEvent {
662            code,
663            modifiers: Modifiers {
664                ctrl: true,
665                ..Default::default()
666            },
667        }
668    }
669
670    fn alt(code: KeyCode) -> KeyEvent {
671        KeyEvent {
672            code,
673            modifiers: Modifiers {
674                alt: true,
675                ..Default::default()
676            },
677        }
678    }
679
680    #[test]
681    fn open_close_lifecycle() {
682        let mut prompt = PromptState::new();
683        assert!(!prompt.is_open());
684        assert_eq!(
685            prompt.handle_key(&key(KeyCode::Char('a'))),
686            PromptAction::Ignored
687        );
688
689        prompt.open(search_spec(), "init");
690        assert!(prompt.is_open());
691        assert_eq!(prompt.spec().unwrap().id, "search");
692        assert_eq!(prompt.input(), "init");
693        assert_eq!(prompt.cursor(), 4);
694
695        prompt.close();
696        assert!(!prompt.is_open());
697        assert_eq!(prompt.input(), "");
698        assert_eq!(prompt.cursor(), 0);
699        assert!(prompt.message().is_none());
700    }
701
702    #[test]
703    fn typing_and_cursor_movement() {
704        let mut prompt = PromptState::new();
705        prompt.open(search_spec(), "");
706
707        prompt.insert("hello");
708        assert_eq!(prompt.input(), "hello");
709        assert_eq!(prompt.cursor(), 5);
710
711        prompt.move_left();
712        prompt.move_left();
713        prompt.insert("X");
714        assert_eq!(prompt.input(), "helXlo");
715
716        prompt.move_home();
717        assert_eq!(prompt.cursor(), 0);
718        prompt.move_end();
719        assert_eq!(prompt.cursor(), 6);
720    }
721
722    #[test]
723    fn editing_is_unicode_safe() {
724        let mut prompt = PromptState::new();
725        prompt.open(search_spec(), "");
726        prompt.insert("héllo");
727        assert_eq!(prompt.cursor(), "héllo".len());
728
729        prompt.move_home();
730        prompt.move_right();
731        assert_eq!(prompt.cursor(), 1);
732        prompt.move_right();
733        // Stepped over the 2-byte "é" to the next boundary.
734        assert_eq!(prompt.cursor(), 3);
735
736        prompt.backspace();
737        assert_eq!(prompt.input(), "hllo");
738        assert!(prompt.input().is_char_boundary(prompt.cursor()));
739    }
740
741    #[test]
742    fn backspace_delete_word_and_clear_line() {
743        let mut prompt = PromptState::new();
744        prompt.open(search_spec(), "");
745        prompt.insert("foo bar baz");
746
747        prompt.delete_word_before();
748        assert_eq!(prompt.input(), "foo bar ");
749
750        prompt.delete_word_before();
751        assert_eq!(prompt.input(), "foo ");
752
753        prompt.clear_to_start();
754        assert_eq!(prompt.input(), "");
755        assert_eq!(prompt.cursor(), 0);
756    }
757
758    #[test]
759    fn enter_submits_and_records_history() {
760        let mut prompt = PromptState::new();
761        prompt.open(search_spec(), "");
762
763        prompt.insert("first");
764        assert_eq!(
765            prompt.handle_key(&key(KeyCode::Enter)),
766            PromptAction::Submitted("first".to_string())
767        );
768        // Still open for the caller to close explicitly.
769        assert!(prompt.is_open());
770        assert_eq!(prompt.history(), &["first".to_string()]);
771        prompt.close();
772
773        prompt.open(search_spec(), "");
774        prompt.insert("second");
775        prompt.submit();
776        prompt.close();
777        assert_eq!(
778            prompt.history(),
779            &["first".to_string(), "second".to_string()]
780        );
781
782        prompt.open(search_spec(), "");
783        prompt.history_prev();
784        assert_eq!(prompt.input(), "second");
785        prompt.history_prev();
786        assert_eq!(prompt.input(), "first");
787        prompt.history_next();
788        assert_eq!(prompt.input(), "second");
789        prompt.history_next();
790        // Back past the end restores the pre-history draft (empty here).
791        assert_eq!(prompt.input(), "");
792    }
793
794    #[test]
795    fn escape_cancels_and_closes() {
796        let mut prompt = PromptState::new();
797        prompt.open(search_spec(), "");
798        prompt.insert("abc");
799        assert_eq!(
800            prompt.handle_key(&key(KeyCode::Escape)),
801            PromptAction::Cancelled
802        );
803        assert!(!prompt.is_open());
804        assert_eq!(prompt.input(), "");
805    }
806
807    #[test]
808    fn handle_key_routes_editing_keys() {
809        let mut prompt = PromptState::new();
810        prompt.open(search_spec(), "");
811
812        assert_eq!(
813            prompt.handle_key(&key(KeyCode::Char('a'))),
814            PromptAction::Editing
815        );
816        assert_eq!(prompt.input(), "a");
817        assert_eq!(
818            prompt.handle_key(&key(KeyCode::Backspace)),
819            PromptAction::Editing
820        );
821        assert_eq!(prompt.input(), "");
822        assert_eq!(
823            prompt.handle_key(&key(KeyCode::Left)),
824            PromptAction::Editing
825        );
826
827        // Plain "space" key name (as sent by some frontends) inserts a space.
828        assert_eq!(
829            prompt.handle_key(&key(KeyCode::Char(' '))),
830            PromptAction::Editing
831        );
832        assert_eq!(prompt.input(), " ");
833    }
834
835    #[test]
836    fn ctrl_shortcuts_edit_input() {
837        let mut prompt = PromptState::new();
838        prompt.open(search_spec(), "");
839        prompt.insert("hello");
840
841        assert_eq!(
842            prompt.handle_key(&ctrl(KeyCode::Char('a'))),
843            PromptAction::Editing
844        );
845        assert_eq!(prompt.cursor(), 0);
846        assert_eq!(
847            prompt.handle_key(&ctrl(KeyCode::Char('e'))),
848            PromptAction::Editing
849        );
850        assert_eq!(prompt.cursor(), 5);
851        assert_eq!(
852            prompt.handle_key(&ctrl(KeyCode::Char('u'))),
853            PromptAction::Editing
854        );
855        assert_eq!(prompt.input(), "");
856    }
857
858    #[test]
859    fn word_movement_and_deletion_shortcuts() {
860        let mut prompt = PromptState::new();
861        prompt.open(search_spec(), "");
862        prompt.insert("hello beautiful world");
863
864        assert_eq!(
865            prompt.handle_key(&ctrl(KeyCode::Left)),
866            PromptAction::Editing
867        );
868        assert_eq!(prompt.cursor(), 16);
869        assert_eq!(
870            prompt.handle_key(&ctrl(KeyCode::Left)),
871            PromptAction::Editing
872        );
873        assert_eq!(prompt.cursor(), 6);
874        assert_eq!(
875            prompt.handle_key(&ctrl(KeyCode::Right)),
876            PromptAction::Editing
877        );
878        assert_eq!(prompt.cursor(), 15);
879
880        assert_eq!(
881            prompt.handle_key(&ctrl(KeyCode::Delete)),
882            PromptAction::Editing
883        );
884        assert_eq!(prompt.input(), "hello beautiful");
885
886        assert_eq!(
887            prompt.handle_key(&ctrl(KeyCode::Backspace)),
888            PromptAction::Editing
889        );
890        assert_eq!(prompt.input(), "hello ");
891
892        prompt.insert("world");
893        assert_eq!(
894            prompt.handle_key(&alt(KeyCode::Backspace)),
895            PromptAction::Editing
896        );
897        assert_eq!(prompt.input(), "hello ");
898
899        prompt.move_home();
900        prompt.insert("new ");
901        prompt.move_home();
902        assert_eq!(
903            prompt.handle_key(&ctrl(KeyCode::Char('k'))),
904            PromptAction::Editing
905        );
906        assert_eq!(prompt.input(), "");
907    }
908
909    #[test]
910    fn items_selection_wraps_and_clamps() {
911        let mut prompt = PromptState::new();
912        prompt.open(palette_spec(), "");
913        prompt.set_items(vec![
914            PromptItem::new("save"),
915            PromptItem::new("quit"),
916            PromptItem::new("write"),
917        ]);
918        assert_eq!(prompt.selected_index(), 0);
919
920        prompt.select_prev();
921        assert_eq!(prompt.selected_index(), 2);
922        prompt.select_next();
923        assert_eq!(prompt.selected_index(), 0);
924        assert_eq!(prompt.selected_item().unwrap().label, "save");
925
926        prompt.set_items(vec![PromptItem::new("only")]);
927        assert_eq!(prompt.selected_index(), 0);
928        prompt.set_items(vec![]);
929        assert!(prompt.selected_item().is_none());
930    }
931
932    #[test]
933    fn tab_completes_selected_label() {
934        let mut prompt = PromptState::new();
935        prompt.open(palette_spec(), "");
936        prompt.set_items(vec![PromptItem::with_hint("save-file", "Ctrl+S")]);
937        assert_eq!(prompt.handle_key(&key(KeyCode::Tab)), PromptAction::Editing);
938        assert_eq!(prompt.input(), "save-file");
939    }
940
941    #[test]
942    fn up_down_prefer_items_over_history() {
943        let mut prompt = PromptState::new();
944        prompt.open(palette_spec(), "");
945        prompt.insert("x");
946        prompt.submit();
947        prompt.insert("y");
948
949        // No items: arrows walk history.
950        assert_eq!(prompt.handle_key(&key(KeyCode::Up)), PromptAction::Editing);
951        assert_eq!(prompt.input(), "x");
952
953        // With items: arrows walk the selection instead.
954        prompt.set_items(vec![PromptItem::new("one"), PromptItem::new("two")]);
955        assert_eq!(
956            prompt.handle_key(&key(KeyCode::Down)),
957            PromptAction::Editing
958        );
959        assert_eq!(prompt.selected_index(), 1);
960        assert_eq!(prompt.input(), "x");
961    }
962
963    #[test]
964    fn arrow_key_aliases_move_cursor() {
965        let mut prompt = PromptState::new();
966        prompt.open(search_spec(), "");
967        prompt.insert("ab");
968
969        // Both platform spellings work for horizontal movement.
970        assert_eq!(
971            prompt.handle_key(&key(KeyCode::Left)),
972            PromptAction::Editing
973        );
974        assert_eq!(prompt.cursor(), 1);
975        assert_eq!(
976            prompt.handle_key(&key(KeyCode::Left)),
977            PromptAction::Editing
978        );
979        assert_eq!(prompt.cursor(), 0);
980        assert_eq!(
981            prompt.handle_key(&key(KeyCode::Right)),
982            PromptAction::Editing
983        );
984        assert_eq!(prompt.cursor(), 1);
985
986        // And for vertical item navigation.
987        prompt.set_items(vec![PromptItem::new("one"), PromptItem::new("two")]);
988        assert_eq!(prompt.handle_key(&key(KeyCode::Up)), PromptAction::Editing);
989        assert_eq!(prompt.selected_index(), 1);
990        assert_eq!(
991            prompt.handle_key(&key(KeyCode::Down)),
992            PromptAction::Editing
993        );
994        assert_eq!(prompt.selected_index(), 0);
995    }
996
997    #[test]
998    fn message_roundtrip() {
999        let mut prompt = PromptState::new();
1000        prompt.open(search_spec(), "");
1001        assert!(prompt.message().is_none());
1002        prompt.set_message("E486: Pattern not found");
1003        assert_eq!(prompt.message(), Some("E486: Pattern not found"));
1004        prompt.clear_message();
1005        assert!(prompt.message().is_none());
1006    }
1007
1008    #[test]
1009    fn fuzzy_score_matches_subsequence_case_insensitively() {
1010        assert!(fuzzy_score("save-file", "satek").is_none());
1011        assert!(fuzzy_score("Save-File", "sf").is_some());
1012        assert!(fuzzy_score("quit", "sf").is_none());
1013        // Consecutive + prefix beats a scattered match.
1014        let strong = fuzzy_score("save", "sa").unwrap();
1015        let weak = fuzzy_score("xsave", "sa").unwrap();
1016        assert!(strong > weak);
1017        // Empty query matches everything neutrally.
1018        assert_eq!(fuzzy_score("anything", ""), Some(0));
1019    }
1020
1021    #[test]
1022    fn fuzzy_filter_sorts_by_score_then_index() {
1023        let items = vec![
1024            PromptItem::new("quit"),
1025            PromptItem::new("save-file"),
1026            PromptItem::new("save-all"),
1027        ];
1028        let ranked = fuzzy_filter(&items, "save");
1029        let ids: Vec<usize> = ranked.iter().map(|(i, _)| *i).collect();
1030        // Non-matches excluded; shorter equal-quality match ranks first.
1031        assert_eq!(ids, vec![2, 1]);
1032
1033        // Exact score ties fall back to index order.
1034        let tied = vec![PromptItem::new("abx"), PromptItem::new("aby")];
1035        let ranked = fuzzy_filter(&tied, "ab");
1036        let ids: Vec<usize> = ranked.iter().map(|(i, _)| *i).collect();
1037        assert_eq!(ids, vec![0, 1]);
1038    }
1039}