1use crate::KeyCode;
2use crate::hook::KeyEvent;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum PromptPlacement {
7 #[default]
9 BottomBar,
10 TopPalette,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PromptItem {
19 pub label: String,
21 pub hint: Option<String>,
23}
24
25impl PromptItem {
26 pub fn new(label: &str) -> Self {
28 Self {
29 label: label.to_string(),
30 hint: None,
31 }
32 }
33
34 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PromptSpec {
47 pub id: &'static str,
49 pub prefix: String,
51 pub placeholder: String,
53 pub placement: PromptPlacement,
55 pub live_update: bool,
58}
59
60impl PromptSpec {
61 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#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PromptAction {
82 Editing,
84 Submitted(String),
87 Cancelled,
89 Ignored,
92}
93
94const HISTORY_LIMIT: usize = 100;
96
97#[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 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn is_open(&self) -> bool {
125 self.spec.is_some()
126 }
127
128 pub fn spec(&self) -> Option<&PromptSpec> {
130 self.spec.as_ref()
131 }
132
133 pub fn input(&self) -> &str {
135 &self.input
136 }
137
138 pub fn cursor(&self) -> usize {
140 self.cursor
141 }
142
143 pub fn items(&self) -> &[PromptItem] {
145 &self.items
146 }
147
148 pub fn selected_index(&self) -> usize {
150 self.selected
151 }
152
153 pub fn selected_item(&self) -> Option<&PromptItem> {
155 self.items.get(self.selected)
156 }
157
158 pub fn message(&self) -> Option<&str> {
160 self.message.as_deref()
161 }
162
163 pub fn history(&self) -> &[String] {
165 &self.history
166 }
167
168 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 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 pub fn set_message(&mut self, message: &str) {
194 self.message = Some(message.to_string());
195 }
196
197 pub fn clear_message(&mut self) {
199 self.message = None;
200 }
201
202 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 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 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 fn abandon_history(&mut self) {
236 self.history_idx = None;
237 self.draft.clear();
238 }
239
240 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 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 pub fn backspace(&mut self) {
259 self.abandon_history();
260 self.backspace_one();
261 }
262
263 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 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 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 pub fn clear_to_start(&mut self) {
316 self.abandon_history();
317 self.input.drain(..self.cursor);
318 self.cursor = 0;
319 }
320
321 pub fn clear_to_end(&mut self) {
323 self.abandon_history();
324 self.input.truncate(self.cursor);
325 }
326
327 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 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 pub fn move_left(&mut self) {
369 self.cursor = self.prev_boundary(self.cursor);
370 }
371
372 pub fn move_right(&mut self) {
374 self.cursor = self.next_boundary(self.cursor);
375 }
376
377 pub fn move_home(&mut self) {
379 self.cursor = 0;
380 }
381
382 pub fn move_end(&mut self) {
384 self.cursor = self.input.len();
385 }
386
387 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 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 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 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 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 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 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
589pub 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
624pub 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 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 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 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 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 assert_eq!(prompt.handle_key(&key(KeyCode::Up)), PromptAction::Editing);
951 assert_eq!(prompt.input(), "x");
952
953 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 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 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 let strong = fuzzy_score("save", "sa").unwrap();
1015 let weak = fuzzy_score("xsave", "sa").unwrap();
1016 assert!(strong > weak);
1017 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 assert_eq!(ids, vec![2, 1]);
1032
1033 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}