1#![forbid(unsafe_code)]
2
3pub mod scorer;
37
38pub use scorer::{
39 BayesianScorer, ConformalRanker, EvidenceKind, EvidenceLedger, IncrementalScorer,
40 IncrementalStats, MatchResult, MatchType, RankConfidence, RankStability, RankedItem,
41 RankedResults, RankingSummary,
42};
43
44use ftui_core::event::{Event, KeyCode, KeyEvent, KeyEventKind, Modifiers};
45use ftui_core::geometry::Rect;
46use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags as CellStyleFlags};
47use ftui_render::frame::Frame;
48use ftui_style::Style;
49use ftui_text::{display_width, grapheme_width, graphemes};
50
51use crate::Widget;
52use crate::adaptive_radix::AdaptiveRadixTree;
53
54#[cfg(feature = "tracing")]
55use tracing::{debug, info};
56#[cfg(feature = "tracing")]
57use web_time::Instant;
58
59#[cfg(feature = "tracing")]
60const TELEMETRY_TARGET: &str = "ftui_widgets::command_palette";
61
62#[cfg(feature = "tracing")]
63fn emit_palette_opened(action_count: usize, result_count: usize) {
64 info!(
65 target: TELEMETRY_TARGET,
66 event = "palette_opened",
67 action_count,
68 result_count
69 );
70}
71
72#[cfg(feature = "tracing")]
73fn emit_palette_query_updated(query: &str, match_count: usize, latency_ms: u128) {
74 info!(
75 target: TELEMETRY_TARGET,
76 event = "palette_query_updated",
77 query_len = query.len(),
78 match_count,
79 latency_ms
80 );
81 if tracing::enabled!(target: TELEMETRY_TARGET, tracing::Level::DEBUG) {
82 debug!(
83 target: TELEMETRY_TARGET,
84 event = "palette_query_text",
85 query
86 );
87 }
88}
89
90#[cfg(feature = "tracing")]
91fn emit_palette_action_executed(action_id: &str, latency_ms: Option<u128>) {
92 if let Some(latency_ms) = latency_ms {
93 info!(
94 target: TELEMETRY_TARGET,
95 event = "palette_action_executed",
96 action_id,
97 latency_ms
98 );
99 } else {
100 info!(
101 target: TELEMETRY_TARGET,
102 event = "palette_action_executed",
103 action_id
104 );
105 }
106}
107
108#[cfg(feature = "tracing")]
109fn emit_palette_closed(reason: PaletteCloseReason) {
110 info!(
111 target: TELEMETRY_TARGET,
112 event = "palette_closed",
113 reason = reason.as_str()
114 );
115}
116
117#[derive(Debug, Clone)]
123pub struct ActionItem {
124 pub id: String,
126 pub title: String,
128 pub description: Option<String>,
130 pub tags: Vec<String>,
132 pub category: Option<String>,
134}
135
136impl ActionItem {
137 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
139 Self {
140 id: id.into(),
141 title: title.into(),
142 description: None,
143 tags: Vec::new(),
144 category: None,
145 }
146 }
147
148 #[must_use]
150 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
151 self.description = Some(desc.into());
152 self
153 }
154
155 #[must_use]
157 pub fn with_tags(mut self, tags: &[&str]) -> Self {
158 self.tags = tags.iter().map(|s| (*s).to_string()).collect();
159 self
160 }
161
162 #[must_use]
164 pub fn with_category(mut self, cat: impl Into<String>) -> Self {
165 self.category = Some(cat.into());
166 self
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum PaletteAction {
177 Execute(String),
179 Dismiss,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum PaletteCloseReason {
185 Dismiss,
186 Execute,
187 Toggle,
188 Programmatic,
189}
190
191impl PaletteCloseReason {
192 #[cfg(feature = "tracing")]
193 const fn as_str(self) -> &'static str {
194 match self {
195 Self::Dismiss => "dismiss",
196 Self::Execute => "execute",
197 Self::Toggle => "toggle",
198 Self::Programmatic => "programmatic",
199 }
200 }
201}
202
203fn compute_word_starts(title_lower: &str) -> Vec<usize> {
204 let bytes = title_lower.as_bytes();
205 title_lower
206 .char_indices()
207 .filter_map(|(i, _)| {
208 let is_word_start = i == 0 || {
209 let prev = bytes.get(i.saturating_sub(1)).copied().unwrap_or(b' ');
210 prev == b' ' || prev == b'-' || prev == b'_'
211 };
212 is_word_start.then_some(i)
213 })
214 .collect()
215}
216
217#[derive(Debug, Clone)]
223pub struct PaletteStyle {
224 pub border: Style,
226 pub input: Style,
228 pub item: Style,
230 pub item_selected: Style,
232 pub match_highlight: Style,
234 pub description: Style,
236 pub category: Style,
238 pub hint: Style,
240}
241
242impl Default for PaletteStyle {
243 fn default() -> Self {
244 Self {
250 border: Style::new().fg(PackedRgba::rgb(100, 100, 120)),
251 input: Style::new().fg(PackedRgba::rgb(220, 220, 230)),
252 item: Style::new().fg(PackedRgba::rgb(190, 190, 200)),
253 item_selected: Style::new()
254 .fg(PackedRgba::rgb(255, 255, 255))
255 .bg(PackedRgba::rgb(50, 50, 75)),
256 match_highlight: Style::new().fg(PackedRgba::rgb(255, 210, 60)),
257 description: Style::new().fg(PackedRgba::rgb(140, 140, 160)),
258 category: Style::new().fg(PackedRgba::rgb(100, 180, 255)),
259 hint: Style::new().fg(PackedRgba::rgb(100, 100, 120)),
260 }
261 }
262}
263
264#[derive(Debug)]
270struct ScoredItem {
271 action_index: usize,
273 result: MatchResult,
275}
276
277#[derive(Debug, Clone, Copy)]
283pub struct PaletteMatch<'a> {
284 pub action: &'a ActionItem,
286 pub result: &'a MatchResult,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum MatchFilter {
297 All,
299 Exact,
301 Prefix,
303 WordStart,
305 Substring,
307 Fuzzy,
309}
310
311impl MatchFilter {
312 fn allows(self, match_type: MatchType) -> bool {
313 matches!(
314 (self, match_type),
315 (Self::All, _)
316 | (Self::Exact, MatchType::Exact)
317 | (Self::Prefix, MatchType::Prefix)
318 | (Self::WordStart, MatchType::WordStart)
319 | (Self::Substring, MatchType::Substring)
320 | (Self::Fuzzy, MatchType::Fuzzy)
321 )
322 }
323}
324
325#[derive(Debug)]
341pub struct CommandPalette {
342 actions: Vec<ActionItem>,
344 titles_cache: Vec<String>,
346 titles_lower: Vec<String>,
348 titles_word_starts: Vec<Vec<usize>>,
350 prefix_index: AdaptiveRadixTree<usize>,
352 query: String,
354 cursor: usize,
356 selected: usize,
358 scroll_offset: usize,
360 visible: bool,
362 style: PaletteStyle,
364 scorer: IncrementalScorer,
366 filtered: Vec<ScoredItem>,
368 match_filter: MatchFilter,
370 generation: u64,
372 max_visible: usize,
374 title: String,
376 fill_area: bool,
379 #[cfg(feature = "tracing")]
381 opened_at: Option<Instant>,
382}
383
384impl Default for CommandPalette {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390impl CommandPalette {
391 pub fn new() -> Self {
393 Self {
394 actions: Vec::new(),
395 titles_cache: Vec::new(),
396 titles_lower: Vec::new(),
397 titles_word_starts: Vec::new(),
398 prefix_index: AdaptiveRadixTree::new(),
399 query: String::new(),
400 cursor: 0,
401 selected: 0,
402 scroll_offset: 0,
403 visible: false,
404 style: PaletteStyle::default(),
405 scorer: IncrementalScorer::new(),
406 filtered: Vec::new(),
407 match_filter: MatchFilter::All,
408 generation: 0,
409 max_visible: 10,
410 title: " Command Palette ".to_string(),
411 fill_area: false,
412 #[cfg(feature = "tracing")]
413 opened_at: None,
414 }
415 }
416
417 #[must_use]
419 pub fn with_style(mut self, style: PaletteStyle) -> Self {
420 self.style = style;
421 self
422 }
423
424 pub fn set_style(&mut self, style: PaletteStyle) {
426 self.style = style;
427 }
428
429 #[must_use]
431 pub fn with_max_visible(mut self, n: usize) -> Self {
432 self.max_visible = n;
433 self
434 }
435
436 #[must_use]
441 pub fn with_title(mut self, title: impl Into<String>) -> Self {
442 self.title = title.into();
443 self
444 }
445
446 pub fn set_title(&mut self, title: impl Into<String>) {
448 self.title = title.into();
449 }
450
451 #[must_use]
459 pub fn with_fill_area(mut self, fill: bool) -> Self {
460 self.fill_area = fill;
461 self
462 }
463
464 pub fn set_fill_area(&mut self, fill: bool) {
466 self.fill_area = fill;
467 }
468
469 pub fn enable_evidence_tracking(&mut self, enabled: bool) {
471 self.scorer = if enabled {
472 IncrementalScorer::with_scorer(BayesianScorer::new())
473 } else {
474 IncrementalScorer::new()
475 };
476 self.update_filtered(false);
477 }
478
479 fn push_title_cache_into(
482 titles_cache: &mut Vec<String>,
483 titles_lower: &mut Vec<String>,
484 titles_word_starts: &mut Vec<Vec<usize>>,
485 title: &str,
486 ) {
487 titles_cache.push(title.to_string());
488 let lower = title.to_lowercase();
489 titles_word_starts.push(compute_word_starts(&lower));
490 titles_lower.push(lower);
491 }
492
493 fn push_title_cache(&mut self, title: &str) {
494 Self::push_title_cache_into(
495 &mut self.titles_cache,
496 &mut self.titles_lower,
497 &mut self.titles_word_starts,
498 title,
499 );
500 }
501
502 fn prefix_index_key(title_lower: &str, action_index: usize) -> String {
503 format!("{title_lower}\u{1f}{action_index:020}")
504 }
505
506 fn insert_prefix_index_entry(&mut self, action_index: usize) {
507 let key = self
508 .titles_lower
509 .get(action_index)
510 .map(|title_lower| Self::prefix_index_key(title_lower, action_index));
511 if let Some(key) = key {
512 self.prefix_index.insert(&key, action_index);
513 }
514 }
515
516 fn rebuild_prefix_index(&mut self) {
517 let mut prefix_index = AdaptiveRadixTree::new();
518 for (action_index, title_lower) in self.titles_lower.iter().enumerate() {
519 let key = Self::prefix_index_key(title_lower, action_index);
520 prefix_index.insert(&key, action_index);
521 }
522 self.prefix_index = prefix_index;
523 }
524
525 fn rebuild_title_cache(&mut self) {
526 self.titles_cache.clear();
527 self.titles_lower.clear();
528 self.titles_word_starts.clear();
529
530 self.titles_cache.reserve(self.actions.len());
531 self.titles_lower.reserve(self.actions.len());
532 self.titles_word_starts.reserve(self.actions.len());
533
534 let titles_cache = &mut self.titles_cache;
535 let titles_lower = &mut self.titles_lower;
536 let titles_word_starts = &mut self.titles_word_starts;
537 for action in &self.actions {
538 Self::push_title_cache_into(
539 titles_cache,
540 titles_lower,
541 titles_word_starts,
542 &action.title,
543 );
544 }
545 }
546
547 fn rebuild_search_cache(&mut self) {
548 self.rebuild_title_cache();
549 self.rebuild_prefix_index();
550 }
551
552 pub fn register(
554 &mut self,
555 title: impl Into<String>,
556 description: Option<&str>,
557 tags: &[&str],
558 ) -> &mut Self {
559 let title = title.into();
560 let id = title.to_lowercase().replace(' ', "_");
561 let mut item = ActionItem::new(id, title);
562 if let Some(desc) = description {
563 item.description = Some(desc.to_string());
564 }
565 item.tags = tags.iter().map(|s| (*s).to_string()).collect();
566 let action_index = self.actions.len();
567 self.push_title_cache(&item.title);
568 self.insert_prefix_index_entry(action_index);
569 self.actions.push(item);
570 self.generation = self.generation.wrapping_add(1);
571 self.refresh_filtered_after_action_change();
572 self
573 }
574
575 pub fn register_action(&mut self, action: ActionItem) -> &mut Self {
577 let action_index = self.actions.len();
578 self.push_title_cache(&action.title);
579 self.insert_prefix_index_entry(action_index);
580 self.actions.push(action);
581 self.generation = self.generation.wrapping_add(1);
582 self.refresh_filtered_after_action_change();
583 self
584 }
585
586 pub fn replace_actions(&mut self, actions: Vec<ActionItem>) {
590 self.actions = actions;
591 self.rebuild_search_cache();
592 self.generation = self.generation.wrapping_add(1);
593 self.scorer.invalidate();
594 self.selected = 0;
595 self.scroll_offset = 0;
596 self.update_filtered(false);
597 }
598
599 pub fn clear_actions(&mut self) {
601 self.replace_actions(Vec::new());
602 }
603
604 pub fn action_count(&self) -> usize {
606 self.actions.len()
607 }
608
609 fn refresh_filtered_after_action_change(&mut self) {
610 if self.visible
611 || !self.query.is_empty()
612 || !self.filtered.is_empty()
613 || self.match_filter != MatchFilter::All
614 {
615 self.update_filtered(false);
616 }
617 }
618
619 pub fn open(&mut self) {
623 self.visible = true;
624 self.query.clear();
625 self.cursor = 0;
626 self.selected = 0;
627 self.scroll_offset = 0;
628 self.scorer.invalidate();
629 #[cfg(feature = "tracing")]
630 {
631 self.opened_at = Some(Instant::now());
632 }
633 self.update_filtered(false);
634 #[cfg(feature = "tracing")]
635 emit_palette_opened(self.actions.len(), self.filtered.len());
636 }
637
638 pub fn close(&mut self) {
640 self.close_with_reason(PaletteCloseReason::Programmatic);
641 }
642
643 pub fn toggle(&mut self) {
645 if self.visible {
646 self.close_with_reason(PaletteCloseReason::Toggle);
647 } else {
648 self.open();
649 }
650 }
651
652 #[inline]
654 pub fn is_visible(&self) -> bool {
655 self.visible
656 }
657
658 pub fn query(&self) -> &str {
662 &self.query
663 }
664
665 pub fn set_query(&mut self, query: impl Into<String>) {
667 self.query = query.into();
668 self.cursor = self.query.len();
669 self.selected = 0;
670 self.scroll_offset = 0;
671 self.scorer.invalidate();
672 self.update_filtered(false);
673 }
674
675 pub fn result_count(&self) -> usize {
677 self.filtered.len()
678 }
679
680 #[inline]
682 pub fn selected_index(&self) -> usize {
683 self.selected
684 }
685
686 #[must_use = "use the selected action (if any)"]
688 pub fn selected_action(&self) -> Option<&ActionItem> {
689 self.filtered
690 .get(self.selected)
691 .map(|si| &self.actions[si.action_index])
692 }
693
694 #[must_use = "use the selected match (if any)"]
696 pub fn selected_match(&self) -> Option<PaletteMatch<'_>> {
697 self.filtered.get(self.selected).map(|si| PaletteMatch {
698 action: &self.actions[si.action_index],
699 result: &si.result,
700 })
701 }
702
703 pub fn results(&self) -> impl Iterator<Item = PaletteMatch<'_>> {
705 self.filtered.iter().map(|si| PaletteMatch {
706 action: &self.actions[si.action_index],
707 result: &si.result,
708 })
709 }
710
711 pub fn set_match_filter(&mut self, filter: MatchFilter) {
713 if self.match_filter == filter {
714 return;
715 }
716 self.match_filter = filter;
717 self.selected = 0;
718 self.scroll_offset = 0;
719 self.update_filtered(false);
720 }
721
722 pub fn scorer_stats(&self) -> &IncrementalStats {
724 self.scorer.stats()
725 }
726
727 #[must_use = "use the returned action (if any) to execute or dismiss"]
735 pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
736 if !self.visible {
737 if let Event::Key(KeyEvent {
739 code: KeyCode::Char('p'),
740 modifiers,
741 kind: KeyEventKind::Press,
742 }) = event
743 && modifiers.contains(Modifiers::CTRL)
744 {
745 self.open();
746 }
747 return None;
748 }
749
750 match event {
751 Event::Key(KeyEvent {
752 code,
753 modifiers,
754 kind: KeyEventKind::Press,
755 }) => self.handle_key(*code, *modifiers),
756 _ => None,
757 }
758 }
759
760 fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Option<PaletteAction> {
762 match code {
763 KeyCode::Escape => {
764 self.close_with_reason(PaletteCloseReason::Dismiss);
765 return Some(PaletteAction::Dismiss);
766 }
767
768 KeyCode::Enter => {
769 if let Some(si) = self.filtered.get(self.selected) {
770 let id = self.actions[si.action_index].id.clone();
771 #[cfg(feature = "tracing")]
772 {
773 let latency_ms = self.opened_at.map(|start| start.elapsed().as_millis());
774 emit_palette_action_executed(&id, latency_ms);
775 }
776 self.close_with_reason(PaletteCloseReason::Execute);
777 return Some(PaletteAction::Execute(id));
778 }
779 }
780
781 KeyCode::Up if self.selected > 0 => {
782 self.selected -= 1;
783 self.adjust_scroll();
784 }
785
786 KeyCode::Down
787 if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 =>
788 {
789 self.selected += 1;
790 self.adjust_scroll();
791 }
792
793 KeyCode::PageUp => {
794 self.selected = self.selected.saturating_sub(self.max_visible);
795 self.adjust_scroll();
796 }
797
798 KeyCode::PageDown if !self.filtered.is_empty() => {
799 self.selected = (self.selected + self.max_visible).min(self.filtered.len() - 1);
800 self.adjust_scroll();
801 }
802
803 KeyCode::Home => {
804 self.selected = 0;
805 self.scroll_offset = 0;
806 }
807
808 KeyCode::End if !self.filtered.is_empty() => {
809 self.selected = self.filtered.len() - 1;
810 self.adjust_scroll();
811 }
812
813 KeyCode::Backspace if self.cursor > 0 => {
814 let prev = self.query[..self.cursor]
816 .char_indices()
817 .next_back()
818 .map(|(i, _)| i)
819 .unwrap_or(0);
820 self.query.drain(prev..self.cursor);
821 self.cursor = prev;
822 self.selected = 0;
823 self.scroll_offset = 0;
824 self.update_filtered(true);
825 }
826
827 KeyCode::Delete if self.cursor < self.query.len() => {
828 let next = self.query[self.cursor..]
830 .char_indices()
831 .nth(1)
832 .map(|(i, _)| self.cursor + i)
833 .unwrap_or(self.query.len());
834 self.query.drain(self.cursor..next);
835 self.selected = 0;
836 self.scroll_offset = 0;
837 self.update_filtered(true);
838 }
839
840 KeyCode::Left if self.cursor > 0 => {
841 self.cursor = self.query[..self.cursor]
843 .char_indices()
844 .next_back()
845 .map(|(i, _)| i)
846 .unwrap_or(0);
847 }
848
849 KeyCode::Right if self.cursor < self.query.len() => {
850 self.cursor = self.query[self.cursor..]
852 .char_indices()
853 .nth(1)
854 .map(|(i, _)| self.cursor + i)
855 .unwrap_or(self.query.len());
856 }
857
858 KeyCode::Char(c) => {
859 if modifiers.contains(Modifiers::CTRL) {
860 if c == 'a' {
862 self.cursor = 0;
863 }
864 if c == 'e' {
866 self.cursor = self.query.len();
867 }
868 if c == 'u' {
870 self.query.clear();
871 self.cursor = 0;
872 self.selected = 0;
873 self.scroll_offset = 0;
874 self.update_filtered(true);
875 }
876 } else {
877 self.query.insert(self.cursor, c);
879 self.cursor += c.len_utf8();
880 self.selected = 0;
881 self.scroll_offset = 0;
882 self.update_filtered(true);
883 }
884 }
885
886 _ => {}
887 }
888
889 None
890 }
891
892 fn update_filtered(&mut self, _emit_telemetry: bool) {
894 #[cfg(feature = "tracing")]
895 let start = _emit_telemetry.then(Instant::now);
899
900 if self.titles_cache.len() != self.actions.len()
901 || self.titles_lower.len() != self.actions.len()
902 || self.titles_word_starts.len() != self.actions.len()
903 || self.prefix_index.len() != self.actions.len()
904 {
905 self.rebuild_search_cache();
906 }
907
908 let results = if self.match_filter == MatchFilter::Prefix && !self.query.is_empty() {
909 let query_lower = self.query.to_lowercase();
910 let candidate_indices: Vec<usize> = self
911 .prefix_index
912 .prefix_scan(&query_lower)
913 .into_iter()
914 .map(|(_, action_index)| *action_index)
915 .collect();
916 self.scorer.score_candidate_indices_with_lowered_and_words(
917 &self.query,
918 &self.titles_cache,
919 &self.titles_lower,
920 &self.titles_word_starts,
921 &candidate_indices,
922 Some(self.generation),
923 )
924 } else {
925 self.scorer.score_corpus_with_lowered_and_words(
926 &self.query,
927 &self.titles_cache,
928 &self.titles_lower,
929 &self.titles_word_starts,
930 Some(self.generation),
931 )
932 };
933
934 self.filtered = results
935 .into_iter()
936 .filter(|(_, result)| self.match_filter.allows(result.match_type))
937 .map(|(idx, result)| ScoredItem {
938 action_index: idx,
939 result,
940 })
941 .collect();
942
943 if !self.filtered.is_empty() {
945 self.selected = self.selected.min(self.filtered.len() - 1);
946 self.adjust_scroll();
947 } else {
948 self.selected = 0;
949 self.scroll_offset = 0;
950 }
951
952 #[cfg(feature = "tracing")]
953 if let Some(start) = start {
954 emit_palette_query_updated(
955 &self.query,
956 self.filtered.len(),
957 start.elapsed().as_millis(),
958 );
959 }
960 }
961
962 fn close_with_reason(&mut self, _reason: PaletteCloseReason) {
963 self.visible = false;
964 self.query.clear();
965 self.cursor = 0;
966 self.filtered.clear();
967 #[cfg(feature = "tracing")]
968 {
969 self.opened_at = None;
970 emit_palette_closed(_reason);
971 }
972 }
973
974 fn adjust_scroll(&mut self) {
976 if self.selected < self.scroll_offset {
977 self.scroll_offset = self.selected;
978 } else if self.selected >= self.scroll_offset.saturating_add(self.max_visible) {
979 self.scroll_offset = self
980 .selected
981 .saturating_sub(self.max_visible.saturating_sub(1));
982 }
983 }
984}
985
986impl Widget for CommandPalette {
991 fn render(&self, area: Rect, frame: &mut Frame) {
992 if !self.visible || area.width < 10 || area.height < 5 {
993 return;
994 }
995
996 let palette_area = if self.fill_area {
997 area
999 } else {
1000 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
1002 let result_rows = self.filtered.len().min(self.max_visible);
1003 let palette_height = (result_rows as u16 + 3)
1005 .max(5)
1006 .min(area.height.saturating_sub(2));
1007 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
1008 let palette_y = area.y + area.height / 6; Rect::new(palette_x, palette_y, palette_width, palette_height)
1010 };
1011
1012 self.clear_area(palette_area, frame);
1014
1015 self.draw_border(palette_area, frame);
1017
1018 let input_area = Rect::new(
1020 palette_area.x + 2,
1021 palette_area.y + 1,
1022 palette_area.width.saturating_sub(4),
1023 1,
1024 );
1025 self.draw_query_input(input_area, frame);
1026
1027 let results_y = palette_area.y + 2;
1029 let results_height = palette_area.height.saturating_sub(3);
1030 let results_area = Rect::new(
1031 palette_area.x + 1,
1032 results_y,
1033 palette_area.width.saturating_sub(2),
1034 results_height,
1035 );
1036 self.draw_results(results_area, frame);
1037
1038 let cursor_visual_pos = display_width(&self.query[..self.cursor.min(self.query.len())]);
1042 let cursor_x = input_area.x + cursor_visual_pos.min(input_area.width as usize) as u16;
1043 frame.cursor_position = Some((cursor_x, input_area.y));
1044 frame.cursor_visible = true;
1045 }
1046
1047 fn is_essential(&self) -> bool {
1048 true
1049 }
1050}
1051
1052impl CommandPalette {
1053 fn palette_background(&self) -> PackedRgba {
1058 self.style
1059 .border
1060 .bg
1061 .or(self.style.input.bg)
1062 .or(self.style.item.bg)
1063 .or(self.style.hint.bg)
1064 .or(self.style.item_selected.bg)
1065 .unwrap_or(PackedRgba::rgb(30, 30, 40))
1066 }
1067
1068 fn clear_area(&self, area: Rect, frame: &mut Frame) {
1070 let bg = self.palette_background();
1071 for y in area.y..area.bottom() {
1072 for x in area.x..area.right() {
1073 if let Some(cell) = frame.buffer.get_mut(x, y) {
1074 *cell = Cell::from_char(' ');
1075 cell.bg = bg;
1076 }
1077 }
1078 }
1079 }
1080
1081 fn draw_border(&self, area: Rect, frame: &mut Frame) {
1083 let border_fg = self
1084 .style
1085 .border
1086 .fg
1087 .unwrap_or(PackedRgba::rgb(100, 100, 120));
1088 let bg = self.palette_background();
1089
1090 let mut cell = Cell::from_char('╭');
1092 cell.fg = border_fg;
1093 cell.bg = bg;
1094 frame.buffer.set_fast(area.x, area.y, cell);
1095
1096 for x in (area.x + 1)..area.right().saturating_sub(1) {
1097 let mut cell = Cell::from_char('─');
1098 cell.fg = border_fg;
1099 cell.bg = bg;
1100 frame.buffer.set_fast(x, area.y, cell);
1101 }
1102 if area.width > 1 {
1103 let mut cell = Cell::from_char('╮');
1104 cell.fg = border_fg;
1105 cell.bg = bg;
1106 frame.buffer.set_fast(area.right() - 1, area.y, cell);
1107 }
1108
1109 let title = &self.title;
1111 let title_width = display_width(title).min(area.width as usize);
1112 let title_x = area.x + (area.width.saturating_sub(title_width as u16)) / 2;
1113 let title_style = Style::new().fg(PackedRgba::rgb(200, 200, 220)).bg(bg);
1114 crate::draw_text_span(frame, title_x, area.y, title, title_style, area.right());
1115
1116 for y in (area.y + 1)..area.bottom().saturating_sub(1) {
1118 let mut cell_l = Cell::from_char('│');
1119 cell_l.fg = border_fg;
1120 cell_l.bg = bg;
1121 frame.buffer.set_fast(area.x, y, cell_l);
1122
1123 if area.width > 1 {
1124 let mut cell_r = Cell::from_char('│');
1125 cell_r.fg = border_fg;
1126 cell_r.bg = bg;
1127 frame.buffer.set_fast(area.right() - 1, y, cell_r);
1128 }
1129 }
1130
1131 if area.height > 1 {
1133 let by = area.bottom() - 1;
1134 let mut cell_bl = Cell::from_char('╰');
1135 cell_bl.fg = border_fg;
1136 cell_bl.bg = bg;
1137 frame.buffer.set_fast(area.x, by, cell_bl);
1138
1139 for x in (area.x + 1)..area.right().saturating_sub(1) {
1140 let mut cell = Cell::from_char('─');
1141 cell.fg = border_fg;
1142 cell.bg = bg;
1143 frame.buffer.set_fast(x, by, cell);
1144 }
1145 if area.width > 1 {
1146 let mut cell_br = Cell::from_char('╯');
1147 cell_br.fg = border_fg;
1148 cell_br.bg = bg;
1149 frame.buffer.set_fast(area.right() - 1, by, cell_br);
1150 }
1151 }
1152 }
1153
1154 fn draw_query_input(&self, area: Rect, frame: &mut Frame) {
1156 let input_fg = self
1157 .style
1158 .input
1159 .fg
1160 .unwrap_or(PackedRgba::rgb(220, 220, 230));
1161 let bg = self.palette_background();
1162 let prompt_fg = PackedRgba::rgb(100, 180, 255);
1163
1164 if let Some(cell) = frame.buffer.get_mut(area.x.saturating_sub(1), area.y) {
1166 cell.content = CellContent::from_char('>');
1167 cell.fg = prompt_fg;
1168 cell.bg = bg;
1169 }
1170
1171 if self.query.is_empty() {
1173 let hint = "Type to search...";
1175 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1176 for (i, ch) in hint.chars().enumerate() {
1177 let x = area.x + i as u16;
1178 if x >= area.right() {
1179 break;
1180 }
1181 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1182 cell.content = CellContent::from_char(ch);
1183 cell.fg = hint_fg;
1184 cell.bg = bg;
1185 }
1186 }
1187 } else {
1188 let mut col = area.x;
1190 for grapheme in graphemes(&self.query) {
1191 let w = grapheme_width(grapheme);
1192 if w == 0 {
1193 continue;
1194 }
1195 if col >= area.right() {
1196 break;
1197 }
1198 if col.saturating_add(w as u16) > area.right() {
1199 break;
1200 }
1201 let content = if w > 1 || grapheme.chars().count() > 1 {
1202 let id = frame.intern_with_width(grapheme, w as u8);
1203 CellContent::from_grapheme(id)
1204 } else if let Some(ch) = grapheme.chars().next() {
1205 CellContent::from_char(ch)
1206 } else {
1207 continue;
1208 };
1209 let mut cell = Cell::new(content);
1210 cell.fg = input_fg;
1211 cell.bg = bg;
1212 frame.buffer.set_fast(col, area.y, cell);
1213 col = col.saturating_add(w as u16);
1214 }
1215 }
1216 }
1217
1218 fn draw_results(&self, area: Rect, frame: &mut Frame) {
1220 if self.filtered.is_empty() {
1221 let msg = if self.query.is_empty() {
1223 "No actions registered"
1224 } else {
1225 "No results"
1226 };
1227 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1228 let bg = self.palette_background();
1229 for (i, ch) in msg.chars().enumerate() {
1230 let x = area.x + 1 + i as u16;
1231 if x >= area.right() {
1232 break;
1233 }
1234 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1235 cell.content = CellContent::from_char(ch);
1236 cell.fg = hint_fg;
1237 cell.bg = bg;
1238 }
1239 }
1240 return;
1241 }
1242
1243 let item_fg = self.style.item.fg.unwrap_or(PackedRgba::rgb(180, 180, 190));
1244 let selected_fg = self
1245 .style
1246 .item_selected
1247 .fg
1248 .unwrap_or(PackedRgba::rgb(255, 255, 255));
1249 let selected_bg = self
1250 .style
1251 .item_selected
1252 .bg
1253 .unwrap_or(PackedRgba::rgb(60, 60, 80));
1254 let highlight_fg = self
1255 .style
1256 .match_highlight
1257 .fg
1258 .unwrap_or(PackedRgba::rgb(255, 200, 50));
1259 let desc_fg = self
1260 .style
1261 .description
1262 .fg
1263 .unwrap_or(PackedRgba::rgb(120, 120, 140));
1264 let cat_fg = self
1265 .style
1266 .category
1267 .fg
1268 .unwrap_or(PackedRgba::rgb(100, 180, 255));
1269 let bg = self.palette_background();
1270
1271 let visible_end = (self.scroll_offset + area.height as usize).min(self.filtered.len());
1272
1273 for (row_idx, si) in self.filtered[self.scroll_offset..visible_end]
1274 .iter()
1275 .enumerate()
1276 {
1277 let y = area.y + row_idx as u16;
1278 if y >= area.bottom() {
1279 break;
1280 }
1281
1282 let action = &self.actions[si.action_index];
1283 let is_selected = (self.scroll_offset + row_idx) == self.selected;
1284
1285 let row_fg = if is_selected { selected_fg } else { item_fg };
1286 let row_bg = if is_selected { selected_bg } else { bg };
1287
1288 let row_attrs = if is_selected {
1290 CellAttrs::new(CellStyleFlags::BOLD, 0)
1291 } else {
1292 CellAttrs::default()
1293 };
1294
1295 for x in area.x..area.right() {
1297 if let Some(cell) = frame.buffer.get_mut(x, y) {
1298 cell.content = CellContent::from_char(' ');
1299 cell.fg = row_fg;
1300 cell.bg = row_bg;
1301 cell.attrs = row_attrs;
1302 }
1303 }
1304
1305 let mut col = area.x;
1307 if is_selected && let Some(cell) = frame.buffer.get_mut(col, y) {
1308 cell.content = CellContent::from_char('>');
1309 cell.fg = highlight_fg;
1310 cell.bg = row_bg;
1311 cell.attrs = CellAttrs::new(CellStyleFlags::BOLD, 0);
1312 }
1313 col += 2;
1314
1315 if let Some(ref cat) = action.category {
1317 let badge = format!("[{}] ", cat);
1318 for grapheme in graphemes(&badge) {
1319 let w = grapheme_width(grapheme);
1320 if w == 0 {
1321 continue;
1322 }
1323 if col >= area.right() || col.saturating_add(w as u16) > area.right() {
1324 break;
1325 }
1326 let content = if w > 1 || grapheme.chars().count() > 1 {
1327 let id = frame.intern_with_width(grapheme, w as u8);
1328 CellContent::from_grapheme(id)
1329 } else if let Some(ch) = grapheme.chars().next() {
1330 CellContent::from_char(ch)
1331 } else {
1332 continue;
1333 };
1334 let mut cell = Cell::new(content);
1335 cell.fg = cat_fg;
1336 cell.bg = row_bg;
1337 cell.attrs = row_attrs;
1338 frame.buffer.set_fast(col, y, cell);
1339 col = col.saturating_add(w as u16);
1340 }
1341 }
1342
1343 let title_max_width = area.right().saturating_sub(col) as usize;
1345 let title_width = display_width(action.title.as_str());
1346 let needs_ellipsis = title_width > title_max_width && title_max_width > 3;
1347 let title_display_width = if needs_ellipsis {
1348 title_max_width.saturating_sub(1) } else {
1350 title_max_width
1351 };
1352
1353 let mut title_used_width = 0usize;
1354 let mut char_idx = 0usize;
1355 let mut match_cursor = 0usize;
1356 let match_positions = &si.result.match_positions;
1357 for grapheme in graphemes(action.title.as_str()) {
1358 let g_chars = grapheme.chars().count();
1359 let char_end = char_idx + g_chars;
1360 while match_cursor < match_positions.len()
1361 && match_positions[match_cursor] < char_idx
1362 {
1363 match_cursor += 1;
1364 }
1365 let is_match = match_cursor < match_positions.len()
1366 && match_positions[match_cursor] < char_end;
1367
1368 let w = grapheme_width(grapheme);
1369 if w == 0 {
1370 char_idx = char_end;
1371 continue;
1372 }
1373 if title_used_width + w > title_display_width || col >= area.right() {
1374 break;
1375 }
1376 if col.saturating_add(w as u16) > area.right() {
1377 break;
1378 }
1379
1380 let content = if w > 1 || grapheme.chars().count() > 1 {
1381 let id = frame.intern_with_width(grapheme, w as u8);
1382 CellContent::from_grapheme(id)
1383 } else if let Some(ch) = grapheme.chars().next() {
1384 CellContent::from_char(ch)
1385 } else {
1386 char_idx = char_end;
1387 continue;
1388 };
1389
1390 let mut cell = Cell::new(content);
1391 cell.fg = if is_match { highlight_fg } else { row_fg };
1392 cell.bg = row_bg;
1393 cell.attrs = row_attrs;
1394 frame.buffer.set_fast(col, y, cell);
1395
1396 col = col.saturating_add(w as u16);
1397 title_used_width += w;
1398 char_idx = char_end;
1399 }
1400
1401 if needs_ellipsis && col < area.right() {
1403 if let Some(cell) = frame.buffer.get_mut(col, y) {
1404 cell.content = CellContent::from_char('\u{2026}'); cell.fg = row_fg;
1406 cell.bg = row_bg;
1407 cell.attrs = row_attrs;
1408 }
1409 col += 1;
1410 }
1411
1412 if let Some(ref desc) = action.description {
1414 col += 2; let max_desc_width = area.right().saturating_sub(col) as usize;
1416 if max_desc_width > 5 {
1417 let desc_width = display_width(desc.as_str());
1418 let desc_needs_ellipsis = desc_width > max_desc_width && max_desc_width > 3;
1419 let desc_display_width = if desc_needs_ellipsis {
1420 max_desc_width.saturating_sub(1)
1421 } else {
1422 max_desc_width
1423 };
1424
1425 let mut desc_used_width = 0usize;
1426 for grapheme in graphemes(desc.as_str()) {
1427 let w = grapheme_width(grapheme);
1428 if w == 0 {
1429 continue;
1430 }
1431 if desc_used_width + w > desc_display_width || col >= area.right() {
1432 break;
1433 }
1434 if col.saturating_add(w as u16) > area.right() {
1435 break;
1436 }
1437 let content = if w > 1 || grapheme.chars().count() > 1 {
1438 let id = frame.intern_with_width(grapheme, w as u8);
1439 CellContent::from_grapheme(id)
1440 } else if let Some(ch) = grapheme.chars().next() {
1441 CellContent::from_char(ch)
1442 } else {
1443 continue;
1444 };
1445 let mut cell = Cell::new(content);
1446 cell.fg = desc_fg;
1447 cell.bg = row_bg;
1448 cell.attrs = row_attrs;
1449 frame.buffer.set_fast(col, y, cell);
1450 col = col.saturating_add(w as u16);
1451 desc_used_width += w;
1452 }
1453
1454 if desc_needs_ellipsis
1455 && col < area.right()
1456 && let Some(cell) = frame.buffer.get_mut(col, y)
1457 {
1458 cell.content = CellContent::from_char('\u{2026}');
1459 cell.fg = desc_fg;
1460 cell.bg = row_bg;
1461 cell.attrs = row_attrs;
1462 }
1463 }
1464 }
1465 }
1466 }
1467}
1468
1469#[cfg(test)]
1474mod widget_tests {
1475 use super::*;
1476
1477 #[test]
1478 fn new_palette_is_hidden() {
1479 let palette = CommandPalette::new();
1480 assert!(!palette.is_visible());
1481 assert_eq!(palette.action_count(), 0);
1482 }
1483
1484 #[test]
1485 fn register_actions() {
1486 let mut palette = CommandPalette::new();
1487 palette.register("Open File", Some("Open a file"), &["file"]);
1488 palette.register("Save File", None, &[]);
1489 assert_eq!(palette.action_count(), 2);
1490 }
1491
1492 #[test]
1493 fn open_shows_all_actions() {
1494 let mut palette = CommandPalette::new();
1495 palette.register("Open File", None, &[]);
1496 palette.register("Save File", None, &[]);
1497 palette.register("Close Tab", None, &[]);
1498 palette.open();
1499 assert!(palette.is_visible());
1500 assert_eq!(palette.result_count(), 3);
1501 }
1502
1503 #[test]
1504 fn close_hides_palette() {
1505 let mut palette = CommandPalette::new();
1506 palette.open();
1507 assert!(palette.is_visible());
1508 palette.close();
1509 assert!(!palette.is_visible());
1510 }
1511
1512 #[test]
1513 fn toggle_visibility() {
1514 let mut palette = CommandPalette::new();
1515 palette.toggle();
1516 assert!(palette.is_visible());
1517 palette.toggle();
1518 assert!(!palette.is_visible());
1519 }
1520
1521 #[test]
1522 fn typing_filters_results() {
1523 let mut palette = CommandPalette::new();
1524 palette.register("Open File", None, &[]);
1525 palette.register("Save File", None, &[]);
1526 palette.register("Git: Commit", None, &[]);
1527 palette.open();
1528 assert_eq!(palette.result_count(), 3);
1529
1530 let g = Event::Key(KeyEvent {
1532 code: KeyCode::Char('g'),
1533 modifiers: Modifiers::empty(),
1534 kind: KeyEventKind::Press,
1535 });
1536 let i = Event::Key(KeyEvent {
1537 code: KeyCode::Char('i'),
1538 modifiers: Modifiers::empty(),
1539 kind: KeyEventKind::Press,
1540 });
1541 let t = Event::Key(KeyEvent {
1542 code: KeyCode::Char('t'),
1543 modifiers: Modifiers::empty(),
1544 kind: KeyEventKind::Press,
1545 });
1546
1547 let _ = palette.handle_event(&g);
1548 let _ = palette.handle_event(&i);
1549 let _ = palette.handle_event(&t);
1550
1551 assert_eq!(palette.query(), "git");
1552 assert!(palette.result_count() >= 1);
1554 }
1555
1556 #[test]
1557 fn backspace_removes_character() {
1558 let mut palette = CommandPalette::new();
1559 palette.register("Open File", None, &[]);
1560 palette.open();
1561
1562 let o = Event::Key(KeyEvent {
1563 code: KeyCode::Char('o'),
1564 modifiers: Modifiers::empty(),
1565 kind: KeyEventKind::Press,
1566 });
1567 let bs = Event::Key(KeyEvent {
1568 code: KeyCode::Backspace,
1569 modifiers: Modifiers::empty(),
1570 kind: KeyEventKind::Press,
1571 });
1572
1573 let _ = palette.handle_event(&o);
1574 assert_eq!(palette.query(), "o");
1575 let _ = palette.handle_event(&bs);
1576 assert_eq!(palette.query(), "");
1577 }
1578
1579 #[test]
1580 fn esc_dismisses_palette() {
1581 let mut palette = CommandPalette::new();
1582 palette.open();
1583
1584 let esc = Event::Key(KeyEvent {
1585 code: KeyCode::Escape,
1586 modifiers: Modifiers::empty(),
1587 kind: KeyEventKind::Press,
1588 });
1589
1590 let result = palette.handle_event(&esc);
1591 assert_eq!(result, Some(PaletteAction::Dismiss));
1592 assert!(!palette.is_visible());
1593 }
1594
1595 #[test]
1596 fn enter_executes_selected() {
1597 let mut palette = CommandPalette::new();
1598 palette.register("Open File", None, &[]);
1599 palette.open();
1600
1601 let enter = Event::Key(KeyEvent {
1602 code: KeyCode::Enter,
1603 modifiers: Modifiers::empty(),
1604 kind: KeyEventKind::Press,
1605 });
1606
1607 let result = palette.handle_event(&enter);
1608 assert_eq!(result, Some(PaletteAction::Execute("open_file".into())));
1609 }
1610
1611 #[test]
1612 fn arrow_keys_navigate() {
1613 let mut palette = CommandPalette::new();
1614 palette.register("A", None, &[]);
1615 palette.register("B", None, &[]);
1616 palette.register("C", None, &[]);
1617 palette.open();
1618
1619 assert_eq!(palette.selected_index(), 0);
1620
1621 let down = Event::Key(KeyEvent {
1622 code: KeyCode::Down,
1623 modifiers: Modifiers::empty(),
1624 kind: KeyEventKind::Press,
1625 });
1626 let up = Event::Key(KeyEvent {
1627 code: KeyCode::Up,
1628 modifiers: Modifiers::empty(),
1629 kind: KeyEventKind::Press,
1630 });
1631
1632 let _ = palette.handle_event(&down);
1633 assert_eq!(palette.selected_index(), 1);
1634 let _ = palette.handle_event(&down);
1635 assert_eq!(palette.selected_index(), 2);
1636 let _ = palette.handle_event(&down);
1638 assert_eq!(palette.selected_index(), 2);
1639
1640 let _ = palette.handle_event(&up);
1641 assert_eq!(palette.selected_index(), 1);
1642 let _ = palette.handle_event(&up);
1643 assert_eq!(palette.selected_index(), 0);
1644 let _ = palette.handle_event(&up);
1646 assert_eq!(palette.selected_index(), 0);
1647 }
1648
1649 #[test]
1650 fn home_end_navigation() {
1651 let mut palette = CommandPalette::new();
1652 for i in 0..20 {
1653 palette.register(format!("Action {}", i), None, &[]);
1654 }
1655 palette.open();
1656
1657 let end = Event::Key(KeyEvent {
1658 code: KeyCode::End,
1659 modifiers: Modifiers::empty(),
1660 kind: KeyEventKind::Press,
1661 });
1662 let home = Event::Key(KeyEvent {
1663 code: KeyCode::Home,
1664 modifiers: Modifiers::empty(),
1665 kind: KeyEventKind::Press,
1666 });
1667
1668 let _ = palette.handle_event(&end);
1669 assert_eq!(palette.selected_index(), 19);
1670
1671 let _ = palette.handle_event(&home);
1672 assert_eq!(palette.selected_index(), 0);
1673 }
1674
1675 #[test]
1676 fn ctrl_u_clears_query() {
1677 let mut palette = CommandPalette::new();
1678 palette.register("Open File", None, &[]);
1679 palette.open();
1680
1681 let o = Event::Key(KeyEvent {
1682 code: KeyCode::Char('o'),
1683 modifiers: Modifiers::empty(),
1684 kind: KeyEventKind::Press,
1685 });
1686 let _ = palette.handle_event(&o);
1687 assert_eq!(palette.query(), "o");
1688
1689 let ctrl_u = Event::Key(KeyEvent {
1690 code: KeyCode::Char('u'),
1691 modifiers: Modifiers::CTRL,
1692 kind: KeyEventKind::Press,
1693 });
1694 let _ = palette.handle_event(&ctrl_u);
1695 assert_eq!(palette.query(), "");
1696 }
1697
1698 #[test]
1699 fn ctrl_p_opens_palette() {
1700 let mut palette = CommandPalette::new();
1701 assert!(!palette.is_visible());
1702
1703 let ctrl_p = Event::Key(KeyEvent {
1704 code: KeyCode::Char('p'),
1705 modifiers: Modifiers::CTRL,
1706 kind: KeyEventKind::Press,
1707 });
1708 let _ = palette.handle_event(&ctrl_p);
1709 assert!(palette.is_visible());
1710 }
1711
1712 #[test]
1713 fn selected_action_returns_correct_item() {
1714 let mut palette = CommandPalette::new();
1715 palette.register("Alpha", None, &[]);
1716 palette.register("Beta", None, &[]);
1717 palette.open();
1718
1719 let action = palette.selected_action().unwrap();
1720 assert!(!action.title.is_empty());
1722 }
1723
1724 #[test]
1725 fn register_action_item_directly() {
1726 let mut palette = CommandPalette::new();
1727 let item = ActionItem::new("custom_id", "Custom Action")
1728 .with_description("A custom action")
1729 .with_tags(&["custom", "test"])
1730 .with_category("Testing");
1731
1732 palette.register_action(item);
1733 assert_eq!(palette.action_count(), 1);
1734 }
1735
1736 #[test]
1737 fn register_refreshes_visible_filtered_results() {
1738 let mut palette = CommandPalette::new();
1739 palette.register("Alpha", None, &[]);
1740 palette.open();
1741 palette.set_query("Beta");
1742 assert!(palette.selected_action().is_none());
1743
1744 palette.register("Beta", None, &[]);
1745
1746 assert_eq!(palette.result_count(), 1);
1747 assert_eq!(
1748 palette
1749 .selected_action()
1750 .map(|action| action.title.as_str()),
1751 Some("Beta")
1752 );
1753 }
1754
1755 #[test]
1756 fn register_action_refreshes_visible_filtered_results() {
1757 let mut palette = CommandPalette::new();
1758 palette.register("Alpha", None, &[]);
1759 palette.open();
1760 palette.set_query("Beta");
1761 assert!(palette.selected_action().is_none());
1762
1763 palette.register_action(ActionItem::new("beta", "Beta"));
1764
1765 assert_eq!(palette.result_count(), 1);
1766 assert_eq!(
1767 palette
1768 .selected_action()
1769 .map(|action| action.title.as_str()),
1770 Some("Beta")
1771 );
1772 }
1773
1774 #[test]
1775 fn replace_actions_refreshes_results() {
1776 let mut palette = CommandPalette::new();
1777 palette.register("Alpha", None, &[]);
1778 palette.register("Beta", None, &[]);
1779 palette.open();
1780 palette.set_query("Beta");
1781 assert_eq!(
1782 palette.selected_action().map(|a| a.title.as_str()),
1783 Some("Beta")
1784 );
1785
1786 let actions = vec![
1787 ActionItem::new("gamma", "Gamma"),
1788 ActionItem::new("delta", "Delta"),
1789 ];
1790 palette.replace_actions(actions);
1791 palette.set_query("Delta");
1792 assert_eq!(
1793 palette.selected_action().map(|a| a.title.as_str()),
1794 Some("Delta")
1795 );
1796 }
1797
1798 #[test]
1799 fn clear_actions_resets_results() {
1800 let mut palette = CommandPalette::new();
1801 palette.register("Alpha", None, &[]);
1802 palette.register("Beta", None, &[]);
1803 palette.open();
1804 palette.set_query("Alpha");
1805 assert!(palette.selected_action().is_some());
1806
1807 palette.clear_actions();
1808 assert_eq!(palette.action_count(), 0);
1809 assert!(palette.selected_action().is_none());
1810 }
1811
1812 #[test]
1813 fn set_query_refilters() {
1814 let mut palette = CommandPalette::new();
1815 palette.register("Alpha", None, &[]);
1816 palette.register("Beta", None, &[]);
1817 palette.open();
1818 palette.set_query("Alpha");
1819 assert_eq!(palette.query(), "Alpha");
1820 assert_eq!(
1821 palette.selected_action().map(|a| a.title.as_str()),
1822 Some("Alpha")
1823 );
1824 palette.set_query("Beta");
1825 assert_eq!(palette.query(), "Beta");
1826 assert_eq!(
1827 palette.selected_action().map(|a| a.title.as_str()),
1828 Some("Beta")
1829 );
1830 }
1831
1832 #[test]
1833 fn events_ignored_when_hidden() {
1834 let mut palette = CommandPalette::new();
1835 let a = Event::Key(KeyEvent {
1837 code: KeyCode::Char('a'),
1838 modifiers: Modifiers::empty(),
1839 kind: KeyEventKind::Press,
1840 });
1841 assert!(palette.handle_event(&a).is_none());
1842 assert!(!palette.is_visible());
1843 }
1844
1845 #[test]
1850 fn selected_row_has_bold_attribute() {
1851 use ftui_render::grapheme_pool::GraphemePool;
1852
1853 let mut palette = CommandPalette::new();
1854 palette.register("Alpha", None, &[]);
1855 palette.register("Beta", None, &[]);
1856 palette.open();
1857
1858 let area = Rect::from_size(60, 10);
1859 let mut pool = GraphemePool::new();
1860 let mut frame = Frame::new(60, 10, &mut pool);
1861 palette.render(area, &mut frame);
1862
1863 let palette_y = area.y + area.height / 6;
1866 let result_y = palette_y + 2;
1867
1868 let mut found_bold = false;
1870 for x in 0..60u16 {
1871 if let Some(cell) = frame.buffer.get(x, result_y)
1872 && cell.attrs.flags().contains(CellStyleFlags::BOLD)
1873 {
1874 found_bold = true;
1875 break;
1876 }
1877 }
1878 assert!(
1879 found_bold,
1880 "Selected row should have bold attribute for accessibility"
1881 );
1882 }
1883
1884 #[test]
1885 fn selection_marker_visible() {
1886 use ftui_render::grapheme_pool::GraphemePool;
1887
1888 let mut palette = CommandPalette::new();
1889 palette.register("Alpha", None, &[]);
1890 palette.open();
1891
1892 let area = Rect::from_size(60, 10);
1893 let mut pool = GraphemePool::new();
1894 let mut frame = Frame::new(60, 10, &mut pool);
1895 palette.render(area, &mut frame);
1896
1897 let palette_y = area.y + area.height / 6;
1899 let result_y = palette_y + 2;
1900 let mut found_marker = false;
1901 for x in 0..60u16 {
1902 if let Some(cell) = frame.buffer.get(x, result_y)
1903 && cell.content.as_char() == Some('>')
1904 {
1905 found_marker = true;
1906 break;
1907 }
1908 }
1909 assert!(
1910 found_marker,
1911 "Selection marker '>' should be visible (color-independent indicator)"
1912 );
1913 }
1914
1915 #[test]
1916 fn long_title_truncated_with_ellipsis() {
1917 use ftui_render::grapheme_pool::GraphemePool;
1918
1919 let mut palette = CommandPalette::new().with_max_visible(5);
1920 palette.register(
1921 "This Is A Very Long Action Title That Should Be Truncated With Ellipsis",
1922 None,
1923 &[],
1924 );
1925 palette.open();
1926
1927 let area = Rect::from_size(40, 10);
1929 let mut pool = GraphemePool::new();
1930 let mut frame = Frame::new(40, 10, &mut pool);
1931 palette.render(area, &mut frame);
1932
1933 let palette_y = area.y + area.height / 6;
1935 let result_y = palette_y + 2;
1936 let mut found_ellipsis = false;
1937 for x in 0..40u16 {
1938 if let Some(cell) = frame.buffer.get(x, result_y)
1939 && cell.content.as_char() == Some('\u{2026}')
1940 {
1941 found_ellipsis = true;
1942 break;
1943 }
1944 }
1945 assert!(
1946 found_ellipsis,
1947 "Long titles should be truncated with '…' ellipsis"
1948 );
1949 }
1950
1951 #[test]
1952 fn keyboard_only_flow_end_to_end() {
1953 let mut palette = CommandPalette::new();
1954 palette.register("Open File", Some("Open a file from disk"), &["file"]);
1955 palette.register("Save File", Some("Save current file"), &["file"]);
1956 palette.register("Git: Commit", Some("Commit changes"), &["git"]);
1957
1958 let ctrl_p = Event::Key(KeyEvent {
1960 code: KeyCode::Char('p'),
1961 modifiers: Modifiers::CTRL,
1962 kind: KeyEventKind::Press,
1963 });
1964 let _ = palette.handle_event(&ctrl_p);
1965 assert!(palette.is_visible());
1966 assert_eq!(palette.result_count(), 3);
1967
1968 for ch in "git".chars() {
1970 let event = Event::Key(KeyEvent {
1971 code: KeyCode::Char(ch),
1972 modifiers: Modifiers::empty(),
1973 kind: KeyEventKind::Press,
1974 });
1975 let _ = palette.handle_event(&event);
1976 }
1977 assert!(palette.result_count() >= 1);
1978
1979 let down = Event::Key(KeyEvent {
1981 code: KeyCode::Down,
1982 modifiers: Modifiers::empty(),
1983 kind: KeyEventKind::Press,
1984 });
1985 let _ = palette.handle_event(&down);
1986
1987 let up = Event::Key(KeyEvent {
1989 code: KeyCode::Up,
1990 modifiers: Modifiers::empty(),
1991 kind: KeyEventKind::Press,
1992 });
1993 let _ = palette.handle_event(&up);
1994 assert_eq!(palette.selected_index(), 0);
1995
1996 let enter = Event::Key(KeyEvent {
1998 code: KeyCode::Enter,
1999 modifiers: Modifiers::empty(),
2000 kind: KeyEventKind::Press,
2001 });
2002 let result = palette.handle_event(&enter);
2003 assert!(matches!(result, Some(PaletteAction::Execute(_))));
2004 assert!(!palette.is_visible());
2005 }
2006
2007 #[test]
2008 fn no_focus_trap_esc_always_dismisses() {
2009 let mut palette = CommandPalette::new();
2010 palette.register("Alpha", None, &[]);
2011 palette.open();
2012
2013 for ch in "xyz".chars() {
2015 let event = Event::Key(KeyEvent {
2016 code: KeyCode::Char(ch),
2017 modifiers: Modifiers::empty(),
2018 kind: KeyEventKind::Press,
2019 });
2020 let _ = palette.handle_event(&event);
2021 }
2022 assert_eq!(palette.result_count(), 0); let esc = Event::Key(KeyEvent {
2026 code: KeyCode::Escape,
2027 modifiers: Modifiers::empty(),
2028 kind: KeyEventKind::Press,
2029 });
2030 let result = palette.handle_event(&esc);
2031 assert_eq!(result, Some(PaletteAction::Dismiss));
2032 assert!(!palette.is_visible());
2033 }
2034
2035 #[test]
2036 fn unicode_query_renders_correctly() {
2037 use ftui_render::grapheme_pool::GraphemePool;
2038
2039 let mut palette = CommandPalette::new();
2040 palette.register("Café Menu", None, &["food"]);
2041 palette.open();
2042 palette.set_query("café");
2043
2044 assert_eq!(palette.query(), "café");
2045
2046 let area = Rect::from_size(60, 10);
2047 let mut pool = GraphemePool::new();
2048 let mut frame = Frame::new(60, 10, &mut pool);
2049 palette.render(area, &mut frame);
2050
2051 let palette_y = area.y + area.height / 6;
2054 let input_y = palette_y + 1;
2055
2056 let mut found_query_chars = 0;
2058 for x in 0..60u16 {
2059 if let Some(cell) = frame.buffer.get(x, input_y)
2060 && let Some(ch) = cell.content.as_char()
2061 && "café".contains(ch)
2062 {
2063 found_query_chars += 1;
2064 }
2065 }
2066 assert!(
2068 found_query_chars >= 3,
2069 "Unicode query should render (found {} chars)",
2070 found_query_chars
2071 );
2072 }
2073
2074 #[test]
2075 fn wide_char_query_renders_correctly() {
2076 use ftui_render::grapheme_pool::GraphemePool;
2077
2078 let mut palette = CommandPalette::new();
2079 palette.register("日本語メニュー", None, &["japanese"]);
2080 palette.open();
2081 palette.set_query("日本");
2082
2083 assert_eq!(palette.query(), "日本");
2084
2085 let area = Rect::from_size(60, 10);
2086 let mut pool = GraphemePool::new();
2087 let mut frame = Frame::new(60, 10, &mut pool);
2088 palette.render(area, &mut frame);
2089
2090 let palette_y = area.y + area.height / 6;
2092 let input_y = palette_y + 1;
2093
2094 let mut found_grapheme = false;
2096 for x in 0..60u16 {
2097 if let Some(cell) = frame.buffer.get(x, input_y)
2098 && cell.content.is_grapheme()
2099 {
2100 found_grapheme = true;
2101 break;
2102 }
2103 }
2104 assert!(
2105 found_grapheme,
2106 "Wide character query should render as graphemes"
2107 );
2108 }
2109
2110 #[test]
2111 fn wcag_aa_contrast_ratios() {
2112 let style = PaletteStyle::default();
2115 let bg = PackedRgba::rgb(30, 30, 40);
2116
2117 fn relative_luminance(color: PackedRgba) -> f64 {
2119 fn linearize(c: u8) -> f64 {
2120 let v = c as f64 / 255.0;
2121 if v <= 0.04045 {
2122 v / 12.92
2123 } else {
2124 ((v + 0.055) / 1.055).powf(2.4)
2125 }
2126 }
2127 let r = linearize(color.r());
2128 let g = linearize(color.g());
2129 let b = linearize(color.b());
2130 0.2126 * r + 0.7152 * g + 0.0722 * b
2131 }
2132
2133 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2134 let l1 = relative_luminance(fg);
2135 let l2 = relative_luminance(bg);
2136 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
2137 (lighter + 0.05) / (darker + 0.05)
2138 }
2139
2140 let item_fg = style.item.fg.unwrap();
2142 let item_ratio = contrast_ratio(item_fg, bg);
2143 assert!(
2144 item_ratio >= 4.5,
2145 "Item text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2146 item_ratio
2147 );
2148
2149 let sel_fg = style.item_selected.fg.unwrap();
2151 let sel_bg = style.item_selected.bg.unwrap();
2152 let sel_ratio = contrast_ratio(sel_fg, sel_bg);
2153 assert!(
2154 sel_ratio >= 4.5,
2155 "Selected text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2156 sel_ratio
2157 );
2158
2159 let hl_fg = style.match_highlight.fg.unwrap();
2161 let hl_ratio = contrast_ratio(hl_fg, bg);
2162 assert!(
2163 hl_ratio >= 4.5,
2164 "Highlight text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2165 hl_ratio
2166 );
2167
2168 let desc_fg = style.description.fg.unwrap();
2170 let desc_ratio = contrast_ratio(desc_fg, bg);
2171 assert!(
2172 desc_ratio >= 4.5,
2173 "Description text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2174 desc_ratio
2175 );
2176 }
2177
2178 #[test]
2179 fn action_item_builder_fields() {
2180 let item = ActionItem::new("my_id", "My Action")
2181 .with_description("A description")
2182 .with_tags(&["tag1", "tag2"])
2183 .with_category("Category");
2184
2185 assert_eq!(item.id, "my_id");
2186 assert_eq!(item.title, "My Action");
2187 assert_eq!(item.description.as_deref(), Some("A description"));
2188 assert_eq!(item.tags, vec!["tag1", "tag2"]);
2189 assert_eq!(item.category.as_deref(), Some("Category"));
2190 }
2191
2192 #[test]
2193 fn action_item_defaults_none() {
2194 let item = ActionItem::new("id", "title");
2195 assert!(item.description.is_none());
2196 assert!(item.tags.is_empty());
2197 assert!(item.category.is_none());
2198 }
2199
2200 #[test]
2201 fn palette_action_equality() {
2202 assert_eq!(PaletteAction::Dismiss, PaletteAction::Dismiss);
2203 assert_eq!(
2204 PaletteAction::Execute("x".into()),
2205 PaletteAction::Execute("x".into())
2206 );
2207 assert_ne!(PaletteAction::Dismiss, PaletteAction::Execute("x".into()));
2208 }
2209
2210 #[test]
2211 fn match_filter_allows_all() {
2212 assert!(MatchFilter::All.allows(MatchType::Exact));
2213 assert!(MatchFilter::All.allows(MatchType::Prefix));
2214 assert!(MatchFilter::All.allows(MatchType::WordStart));
2215 assert!(MatchFilter::All.allows(MatchType::Substring));
2216 assert!(MatchFilter::All.allows(MatchType::Fuzzy));
2217 }
2218
2219 #[test]
2220 fn match_filter_specific_types() {
2221 assert!(MatchFilter::Exact.allows(MatchType::Exact));
2222 assert!(!MatchFilter::Exact.allows(MatchType::Fuzzy));
2223 assert!(MatchFilter::Fuzzy.allows(MatchType::Fuzzy));
2224 assert!(!MatchFilter::Fuzzy.allows(MatchType::Exact));
2225 }
2226
2227 #[test]
2228 fn palette_default_trait() {
2229 let palette = CommandPalette::default();
2230 assert!(!palette.is_visible());
2231 assert_eq!(palette.action_count(), 0);
2232 assert_eq!(palette.query(), "");
2233 }
2234
2235 #[test]
2236 fn with_max_visible_builder() {
2237 let palette = CommandPalette::new().with_max_visible(5);
2238 let mut palette = palette;
2240 for i in 0..10 {
2241 palette.register(format!("Action {i}"), None, &[]);
2242 }
2243 palette.open();
2244 assert_eq!(palette.result_count(), 10);
2245 }
2246
2247 #[test]
2248 fn scorer_stats_accessible() {
2249 let mut palette = CommandPalette::new();
2250 palette.register("Alpha", None, &[]);
2251 palette.open();
2252 palette.set_query("a");
2253 let stats = palette.scorer_stats();
2254 assert!(stats.full_scans + stats.incremental_scans >= 1);
2255 }
2256
2257 #[test]
2258 fn selected_match_returns_match() {
2259 let mut palette = CommandPalette::new();
2260 palette.register("Hello World", None, &[]);
2261 palette.open();
2262 palette.set_query("hello");
2263 let m = palette.selected_match();
2264 assert!(m.is_some());
2265 assert_eq!(m.unwrap().action.title, "Hello World");
2266 }
2267
2268 #[test]
2269 fn results_iterator_returns_matches() {
2270 let mut palette = CommandPalette::new();
2271 palette.register("Alpha", None, &[]);
2272 palette.register("Beta", None, &[]);
2273 palette.open();
2274 let count = palette.results().count();
2275 assert_eq!(count, 2);
2276 }
2277
2278 #[test]
2279 fn set_match_filter_narrows_results() {
2280 let mut palette = CommandPalette::new();
2281 palette.register("Open File", None, &[]);
2282 palette.register("Save File", None, &[]);
2283 palette.open();
2284 palette.set_query("open");
2285 let before = palette.result_count();
2286
2287 palette.set_match_filter(MatchFilter::Exact);
2289 let after = palette.result_count();
2290 assert!(after <= before);
2291 }
2292
2293 #[test]
2294 fn prefix_filter_uses_art_candidate_set() {
2295 let mut palette = CommandPalette::new();
2296 for idx in 0..40 {
2297 palette.register_action(ActionItem::new(
2298 format!("file-{idx:02}"),
2299 format!("File Command {idx:02}"),
2300 ));
2301 }
2302 for idx in 0..80 {
2303 palette.register_action(ActionItem::new(
2304 format!("edit-{idx:02}"),
2305 format!("Edit Command {idx:02}"),
2306 ));
2307 }
2308
2309 palette.open();
2310 palette.set_match_filter(MatchFilter::Prefix);
2311
2312 let before = palette.scorer_stats().total_evaluated;
2313 palette.set_query("file");
2314 let evaluated_for_query = palette.scorer_stats().total_evaluated - before;
2315
2316 assert_eq!(palette.result_count(), 40);
2317 assert_eq!(evaluated_for_query, 40);
2318 }
2319
2320 #[test]
2321 fn prefix_filter_preserves_duplicate_titles() {
2322 let mut palette = CommandPalette::new();
2323 palette.register_action(ActionItem::new("open-file-a", "Open File"));
2324 palette.register_action(ActionItem::new("open-file-b", "Open File"));
2325 palette.register_action(ActionItem::new("open-folder", "Open Folder"));
2326
2327 palette.open();
2328 palette.set_match_filter(MatchFilter::Prefix);
2329 palette.set_query("open f");
2330
2331 let ids: Vec<&str> = palette.results().map(|m| m.action.id.as_str()).collect();
2332 assert_eq!(ids.len(), 3);
2333 assert!(ids.contains(&"open-file-a"));
2334 assert!(ids.contains(&"open-file-b"));
2335 assert!(ids.contains(&"open-folder"));
2336 }
2337
2338 #[test]
2339 fn prefix_filter_does_not_poison_all_match_cache() {
2340 let mut palette = CommandPalette::new();
2341 palette.register("Open Preferences", None, &[]);
2342 palette.register("Preferences: Open", None, &[]);
2343
2344 palette.open();
2345 palette.set_match_filter(MatchFilter::Prefix);
2346 palette.set_query("pref");
2347 assert_eq!(palette.result_count(), 1);
2348
2349 palette.set_match_filter(MatchFilter::All);
2350 let titles: Vec<&str> = palette.results().map(|m| m.action.title.as_str()).collect();
2351 assert!(titles.contains(&"Open Preferences"));
2352 assert!(titles.contains(&"Preferences: Open"));
2353 }
2354
2355 #[test]
2356 fn replace_actions_rebuilds_prefix_index() {
2357 let mut palette = CommandPalette::new();
2358 palette.register_action(ActionItem::new("open-file", "Open File"));
2359
2360 palette.open();
2361 palette.set_match_filter(MatchFilter::Prefix);
2362 palette.set_query("open");
2363 assert_eq!(palette.result_count(), 1);
2364
2365 palette.replace_actions(vec![ActionItem::new("save-file", "Save File")]);
2366 assert_eq!(palette.result_count(), 0);
2367
2368 palette.set_query("save");
2369 assert_eq!(palette.result_count(), 1);
2370 assert_eq!(
2371 palette.selected_action().map(|action| action.id.as_str()),
2372 Some("save-file")
2373 );
2374 }
2375
2376 #[test]
2377 fn enter_with_no_results_returns_none() {
2378 let mut palette = CommandPalette::new();
2379 palette.register("Alpha", None, &[]);
2380 palette.open();
2381 palette.set_query("zzzznotfound");
2382 assert_eq!(palette.result_count(), 0);
2383
2384 let enter = Event::Key(KeyEvent {
2385 code: KeyCode::Enter,
2386 modifiers: Modifiers::empty(),
2387 kind: KeyEventKind::Press,
2388 });
2389 let result = palette.handle_event(&enter);
2390 assert!(result.is_none());
2391 }
2392
2393 #[cfg(feature = "tracing")]
2394 #[test]
2395 fn telemetry_emits_in_order() {
2396 use std::sync::{Arc, Mutex};
2397 use tracing::Subscriber;
2398 use tracing_subscriber::Layer;
2399 use tracing_subscriber::filter::Targets;
2400 use tracing_subscriber::layer::{Context, SubscriberExt};
2401
2402 #[derive(Default)]
2403 struct EventCapture {
2404 events: Arc<Mutex<Vec<String>>>,
2405 }
2406
2407 impl<S> Layer<S> for EventCapture
2408 where
2409 S: Subscriber,
2410 {
2411 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
2412 use tracing::field::{Field, Visit};
2413
2414 struct EventVisitor {
2415 name: Option<String>,
2416 }
2417
2418 impl Visit for EventVisitor {
2419 fn record_str(&mut self, field: &Field, value: &str) {
2420 if field.name() == "event" {
2421 self.name = Some(value.to_string());
2422 }
2423 }
2424
2425 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2426 if field.name() == "event" {
2427 let raw = format!("{value:?}");
2428 let normalized = raw.trim_matches('\"').to_string();
2429 self.name = Some(normalized);
2430 }
2431 }
2432 }
2433
2434 let mut visitor = EventVisitor { name: None };
2435 event.record(&mut visitor);
2436 if let Some(name) = visitor.name {
2437 self.events
2438 .lock()
2439 .expect("lock telemetry events")
2440 .push(name);
2441 }
2442 }
2443 }
2444
2445 let events = Arc::new(Mutex::new(Vec::new()));
2446 let capture = EventCapture {
2447 events: Arc::clone(&events),
2448 };
2449 let _trace_test_guard = crate::tracing_test_support::acquire();
2450
2451 let subscriber = tracing_subscriber::registry()
2452 .with(capture)
2453 .with(Targets::new().with_target(TELEMETRY_TARGET, tracing::Level::INFO));
2454 let _guard = tracing::subscriber::set_default(subscriber);
2455
2456 let mut palette = CommandPalette::new();
2460 palette.register("Alpha", None, &[]);
2461 tracing::callsite::rebuild_interest_cache();
2462 palette.open();
2463
2464 let a = Event::Key(KeyEvent {
2465 code: KeyCode::Char('a'),
2466 modifiers: Modifiers::empty(),
2467 kind: KeyEventKind::Press,
2468 });
2469 tracing::callsite::rebuild_interest_cache();
2470 let _ = palette.handle_event(&a);
2471
2472 let enter = Event::Key(KeyEvent {
2473 code: KeyCode::Enter,
2474 modifiers: Modifiers::empty(),
2475 kind: KeyEventKind::Press,
2476 });
2477 tracing::callsite::rebuild_interest_cache();
2478 let _ = palette.handle_event(&enter);
2479 tracing::callsite::rebuild_interest_cache();
2480 palette.close();
2481
2482 let events = events.lock().expect("lock telemetry events");
2483 let open_idx = events
2484 .iter()
2485 .position(|e| e == "palette_opened")
2486 .expect("palette_opened missing");
2487 let query_idx = events
2488 .iter()
2489 .position(|e| e == "palette_query_updated")
2490 .expect("palette_query_updated missing");
2491 let exec_idx = events
2492 .iter()
2493 .position(|e| e == "palette_action_executed")
2494 .expect("palette_action_executed missing");
2495 let close_idx = events
2496 .iter()
2497 .position(|e| e == "palette_closed")
2498 .expect("palette_closed missing");
2499
2500 assert!(open_idx < query_idx);
2501 assert!(query_idx < exec_idx);
2502 assert!(exec_idx < close_idx);
2503 }
2504
2505 #[test]
2510 fn compute_word_starts_empty() {
2511 let starts = compute_word_starts("");
2512 assert!(starts.is_empty());
2513 }
2514
2515 #[test]
2516 fn compute_word_starts_single_word() {
2517 let starts = compute_word_starts("hello");
2518 assert_eq!(starts, vec![0]);
2519 }
2520
2521 #[test]
2522 fn compute_word_starts_spaces() {
2523 let starts = compute_word_starts("open file now");
2524 assert_eq!(starts, vec![0, 5, 10]);
2525 }
2526
2527 #[test]
2528 fn compute_word_starts_hyphen_underscore() {
2529 let starts = compute_word_starts("git-commit_push");
2530 assert_eq!(starts, vec![0, 4, 11]);
2532 }
2533
2534 #[test]
2535 fn compute_word_starts_all_separators() {
2536 let starts = compute_word_starts("- _");
2537 assert_eq!(starts, vec![0, 1, 2]);
2540 }
2541
2542 #[test]
2543 fn backspace_on_empty_query_is_noop() {
2544 let mut palette = CommandPalette::new();
2545 palette.register("Alpha", None, &[]);
2546 palette.open();
2547 assert_eq!(palette.query(), "");
2548
2549 let bs = Event::Key(KeyEvent {
2550 code: KeyCode::Backspace,
2551 modifiers: Modifiers::empty(),
2552 kind: KeyEventKind::Press,
2553 });
2554 let _ = palette.handle_event(&bs);
2555 assert_eq!(palette.query(), "");
2556 assert_eq!(palette.result_count(), 1);
2558 }
2559
2560 #[test]
2561 fn ctrl_a_moves_cursor_to_start() {
2562 let mut palette = CommandPalette::new();
2563 palette.register("Alpha", None, &[]);
2564 palette.open();
2565
2566 for ch in "abc".chars() {
2568 let event = Event::Key(KeyEvent {
2569 code: KeyCode::Char(ch),
2570 modifiers: Modifiers::empty(),
2571 kind: KeyEventKind::Press,
2572 });
2573 let _ = palette.handle_event(&event);
2574 }
2575 assert_eq!(palette.query(), "abc");
2576
2577 let ctrl_a = Event::Key(KeyEvent {
2578 code: KeyCode::Char('a'),
2579 modifiers: Modifiers::CTRL,
2580 kind: KeyEventKind::Press,
2581 });
2582 let _ = palette.handle_event(&ctrl_a);
2583 assert_eq!(palette.query(), "abc");
2585 }
2586
2587 #[test]
2588 fn key_release_events_ignored() {
2589 let mut palette = CommandPalette::new();
2590 palette.register("Alpha", None, &[]);
2591 palette.open();
2592
2593 let release = Event::Key(KeyEvent {
2594 code: KeyCode::Char('x'),
2595 modifiers: Modifiers::empty(),
2596 kind: KeyEventKind::Release,
2597 });
2598 let result = palette.handle_event(&release);
2599 assert!(result.is_none());
2600 assert_eq!(palette.query(), "");
2601 }
2602
2603 #[test]
2604 fn resize_event_ignored() {
2605 let mut palette = CommandPalette::new();
2606 palette.open();
2607
2608 let resize = Event::Resize {
2609 width: 80,
2610 height: 24,
2611 };
2612 let result = palette.handle_event(&resize);
2613 assert!(result.is_none());
2614 }
2615
2616 #[test]
2617 fn is_essential_returns_true() {
2618 let palette = CommandPalette::new();
2619 assert!(palette.is_essential());
2620 }
2621
2622 #[test]
2623 fn render_too_small_area_noop() {
2624 use ftui_render::grapheme_pool::GraphemePool;
2625
2626 let mut palette = CommandPalette::new();
2627 palette.register("Alpha", None, &[]);
2628 palette.open();
2629
2630 let area = Rect::new(0, 0, 9, 10);
2632 let mut pool = GraphemePool::new();
2633 let mut frame = Frame::new(20, 20, &mut pool);
2634 palette.render(area, &mut frame);
2635 assert!(frame.cursor_position.is_none());
2637 }
2638
2639 #[test]
2640 fn render_too_short_area_noop() {
2641 use ftui_render::grapheme_pool::GraphemePool;
2642
2643 let mut palette = CommandPalette::new();
2644 palette.register("Alpha", None, &[]);
2645 palette.open();
2646
2647 let area = Rect::new(0, 0, 60, 4);
2649 let mut pool = GraphemePool::new();
2650 let mut frame = Frame::new(60, 10, &mut pool);
2651 palette.render(area, &mut frame);
2652 assert!(frame.cursor_position.is_none());
2653 }
2654
2655 #[test]
2656 fn render_hidden_palette_noop() {
2657 use ftui_render::grapheme_pool::GraphemePool;
2658
2659 let palette = CommandPalette::new();
2660 assert!(!palette.is_visible());
2661
2662 let area = Rect::from_size(60, 10);
2663 let mut pool = GraphemePool::new();
2664 let mut frame = Frame::new(60, 10, &mut pool);
2665 palette.render(area, &mut frame);
2666 assert!(frame.cursor_position.is_none());
2667 }
2668
2669 #[test]
2670 fn render_empty_palette_shows_no_actions_hint() {
2671 use ftui_render::grapheme_pool::GraphemePool;
2672
2673 let mut palette = CommandPalette::new();
2674 palette.open();
2676
2677 let area = Rect::from_size(60, 15);
2678 let mut pool = GraphemePool::new();
2679 let mut frame = Frame::new(60, 15, &mut pool);
2680 palette.render(area, &mut frame);
2681
2682 let palette_y = area.y + area.height / 6;
2684 let result_y = palette_y + 2;
2685 let mut found_n = false;
2686 for x in 0..60u16 {
2687 if let Some(cell) = frame.buffer.get(x, result_y)
2688 && cell.content.as_char() == Some('N')
2689 {
2690 found_n = true;
2691 break;
2692 }
2693 }
2694 assert!(found_n, "Should render 'No actions registered' hint");
2695 }
2696
2697 #[test]
2698 fn render_query_no_results_shows_hint() {
2699 use ftui_render::grapheme_pool::GraphemePool;
2700
2701 let mut palette = CommandPalette::new();
2702 palette.register("Alpha", None, &[]);
2703 palette.open();
2704 palette.set_query("zzzznotfound");
2705 assert_eq!(palette.result_count(), 0);
2706
2707 let area = Rect::from_size(60, 15);
2708 let mut pool = GraphemePool::new();
2709 let mut frame = Frame::new(60, 15, &mut pool);
2710 palette.render(area, &mut frame);
2711
2712 let palette_y = area.y + area.height / 6;
2714 let result_y = palette_y + 2;
2715 let mut found_n = false;
2716 for x in 0..60u16 {
2717 if let Some(cell) = frame.buffer.get(x, result_y)
2718 && cell.content.as_char() == Some('N')
2719 {
2720 found_n = true;
2721 break;
2722 }
2723 }
2724 assert!(found_n, "Should render 'No results' hint");
2725 }
2726
2727 #[test]
2728 fn render_with_category_badge() {
2729 use ftui_render::grapheme_pool::GraphemePool;
2730
2731 let mut palette = CommandPalette::new();
2732 let item = ActionItem::new("git_commit", "Commit Changes").with_category("Git");
2733 palette.register_action(item);
2734 palette.open();
2735
2736 let area = Rect::from_size(80, 15);
2737 let mut pool = GraphemePool::new();
2738 let mut frame = Frame::new(80, 15, &mut pool);
2739 palette.render(area, &mut frame);
2740
2741 let palette_y = area.y + area.height / 6;
2743 let result_y = palette_y + 2;
2744 let mut found_bracket = false;
2745 for x in 0..80u16 {
2746 if let Some(cell) = frame.buffer.get(x, result_y)
2747 && cell.content.as_char() == Some('[')
2748 {
2749 found_bracket = true;
2750 break;
2751 }
2752 }
2753 assert!(found_bracket, "Should render category badge '[Git]'");
2754 }
2755
2756 #[test]
2757 fn render_with_description_text() {
2758 use ftui_render::grapheme_pool::GraphemePool;
2759
2760 let mut palette = CommandPalette::new();
2761 palette.register("Open File", Some("Opens a file from disk"), &[]);
2762 palette.open();
2763
2764 let area = Rect::from_size(80, 15);
2765 let mut pool = GraphemePool::new();
2766 let mut frame = Frame::new(80, 15, &mut pool);
2767 palette.render(area, &mut frame);
2768
2769 let palette_y = area.y + area.height / 6;
2771 let result_y = palette_y + 2;
2772 let mut found_desc_char = false;
2773 for x in 20..80u16 {
2775 if let Some(cell) = frame.buffer.get(x, result_y)
2776 && cell.content.as_char() == Some('O')
2777 {
2778 found_desc_char = true;
2779 break;
2780 }
2781 }
2782 assert!(found_desc_char, "Description text should be rendered");
2783 }
2784
2785 #[test]
2786 fn open_resets_previous_state() {
2787 let mut palette = CommandPalette::new();
2788 palette.register("Alpha", None, &[]);
2789 palette.register("Beta", None, &[]);
2790 palette.open();
2791 palette.set_query("Alpha");
2792
2793 let down = Event::Key(KeyEvent {
2795 code: KeyCode::Down,
2796 modifiers: Modifiers::empty(),
2797 kind: KeyEventKind::Press,
2798 });
2799 let _ = palette.handle_event(&down);
2800
2801 palette.open();
2803 assert_eq!(palette.query(), "");
2804 assert_eq!(palette.selected_index(), 0);
2805 assert_eq!(palette.result_count(), 2);
2806 }
2807
2808 #[test]
2809 fn set_match_filter_same_value_is_noop() {
2810 let mut palette = CommandPalette::new();
2811 palette.register("Alpha", None, &[]);
2812 palette.open();
2813 palette.set_query("alpha");
2814
2815 palette.set_match_filter(MatchFilter::All);
2816 let count1 = palette.result_count();
2817 palette.set_match_filter(MatchFilter::All);
2819 assert_eq!(palette.result_count(), count1);
2820 }
2821
2822 #[test]
2823 fn generation_increments_on_register() {
2824 let mut palette = CommandPalette::new();
2825 palette.register("A", None, &[]);
2826 palette.register("B", None, &[]);
2827 palette.replace_actions(vec![ActionItem::new("c", "C")]);
2830 palette.open();
2831 assert_eq!(palette.action_count(), 1);
2832 }
2833
2834 #[test]
2835 fn enable_evidence_tracking_toggle() {
2836 let mut palette = CommandPalette::new();
2837 palette.register("Alpha", None, &[]);
2838 palette.open();
2839
2840 palette.enable_evidence_tracking(true);
2841 palette.set_query("alpha");
2842 assert!(palette.result_count() >= 1);
2843
2844 palette.enable_evidence_tracking(false);
2845 palette.set_query("alpha");
2846 assert!(palette.result_count() >= 1);
2847 }
2848
2849 #[test]
2850 fn register_chaining() {
2851 let mut palette = CommandPalette::new();
2852 palette
2853 .register("A", None, &[])
2854 .register("B", None, &[])
2855 .register("C", Some("desc"), &["tag"]);
2856 assert_eq!(palette.action_count(), 3);
2857 }
2858
2859 #[test]
2860 fn register_action_chaining() {
2861 let mut palette = CommandPalette::new();
2862 palette
2863 .register_action(ActionItem::new("a", "A"))
2864 .register_action(ActionItem::new("b", "B"));
2865 assert_eq!(palette.action_count(), 2);
2866 }
2867
2868 #[test]
2869 fn page_up_down_navigation() {
2870 let mut palette = CommandPalette::new().with_max_visible(3);
2871 for i in 0..10 {
2872 palette.register(format!("Action {i}"), None, &[]);
2873 }
2874 palette.open();
2875 assert_eq!(palette.selected_index(), 0);
2876
2877 let pgdn = Event::Key(KeyEvent {
2878 code: KeyCode::PageDown,
2879 modifiers: Modifiers::empty(),
2880 kind: KeyEventKind::Press,
2881 });
2882 let _ = palette.handle_event(&pgdn);
2883 assert_eq!(palette.selected_index(), 3); let _ = palette.handle_event(&pgdn);
2886 assert_eq!(palette.selected_index(), 6);
2887
2888 let _ = palette.handle_event(&pgdn);
2889 assert_eq!(palette.selected_index(), 9); let pgup = Event::Key(KeyEvent {
2892 code: KeyCode::PageUp,
2893 modifiers: Modifiers::empty(),
2894 kind: KeyEventKind::Press,
2895 });
2896 let _ = palette.handle_event(&pgup);
2897 assert_eq!(palette.selected_index(), 6); let _ = palette.handle_event(&pgup);
2900 assert_eq!(palette.selected_index(), 3);
2901
2902 let _ = palette.handle_event(&pgup);
2903 assert_eq!(palette.selected_index(), 0);
2904 }
2905
2906 #[test]
2907 fn page_down_empty_results_is_noop() {
2908 let mut palette = CommandPalette::new();
2909 palette.open();
2910 assert_eq!(palette.result_count(), 0);
2911
2912 let pgdn = Event::Key(KeyEvent {
2913 code: KeyCode::PageDown,
2914 modifiers: Modifiers::empty(),
2915 kind: KeyEventKind::Press,
2916 });
2917 let _ = palette.handle_event(&pgdn);
2918 assert_eq!(palette.selected_index(), 0);
2919 }
2920
2921 #[test]
2922 fn end_empty_results_is_noop() {
2923 let mut palette = CommandPalette::new();
2924 palette.open();
2925 assert_eq!(palette.result_count(), 0);
2926
2927 let end = Event::Key(KeyEvent {
2928 code: KeyCode::End,
2929 modifiers: Modifiers::empty(),
2930 kind: KeyEventKind::Press,
2931 });
2932 let _ = palette.handle_event(&end);
2933 assert_eq!(palette.selected_index(), 0);
2934 }
2935
2936 #[test]
2937 fn down_empty_results_is_noop() {
2938 let mut palette = CommandPalette::new();
2939 palette.open();
2940 assert_eq!(palette.result_count(), 0);
2941
2942 let down = Event::Key(KeyEvent {
2943 code: KeyCode::Down,
2944 modifiers: Modifiers::empty(),
2945 kind: KeyEventKind::Press,
2946 });
2947 let _ = palette.handle_event(&down);
2948 assert_eq!(palette.selected_index(), 0);
2949 }
2950
2951 #[test]
2952 fn selected_action_none_when_empty() {
2953 let mut palette = CommandPalette::new();
2954 palette.open();
2955 assert!(palette.selected_action().is_none());
2956 assert!(palette.selected_match().is_none());
2957 }
2958
2959 #[test]
2960 fn results_iterator_empty() {
2961 let mut palette = CommandPalette::new();
2962 palette.open();
2963 assert_eq!(palette.results().count(), 0);
2964 }
2965
2966 #[test]
2967 fn scroll_adjust_keeps_selection_visible() {
2968 let mut palette = CommandPalette::new().with_max_visible(3);
2969 for i in 0..10 {
2970 palette.register(format!("Action {i}"), None, &[]);
2971 }
2972 palette.open();
2973
2974 let end = Event::Key(KeyEvent {
2975 code: KeyCode::End,
2976 modifiers: Modifiers::empty(),
2977 kind: KeyEventKind::Press,
2978 });
2979 let _ = palette.handle_event(&end);
2980 assert_eq!(palette.selected_index(), 9);
2981 let home = Event::Key(KeyEvent {
2985 code: KeyCode::Home,
2986 modifiers: Modifiers::empty(),
2987 kind: KeyEventKind::Press,
2988 });
2989 let _ = palette.handle_event(&home);
2990 assert_eq!(palette.selected_index(), 0);
2991 }
2992
2993 #[test]
2994 fn update_filtered_clamps_scroll_offset_after_results_shrink() {
2995 let mut palette = CommandPalette::new().with_max_visible(3);
2996 for i in 0..10 {
2997 palette.register(format!("Action {i}"), None, &[]);
2998 }
2999 palette.open();
3000
3001 let end = Event::Key(KeyEvent {
3002 code: KeyCode::End,
3003 modifiers: Modifiers::empty(),
3004 kind: KeyEventKind::Press,
3005 });
3006 let _ = palette.handle_event(&end);
3007 assert!(palette.scroll_offset > 0);
3008
3009 palette.actions.truncate(1);
3010 palette.rebuild_title_cache();
3011 palette.generation = palette.generation.wrapping_add(1);
3012 palette.update_filtered(false);
3013
3014 assert_eq!(palette.result_count(), 1);
3015 assert_eq!(palette.selected, 0);
3016 assert_eq!(palette.scroll_offset, 0);
3017 assert_eq!(
3018 palette
3019 .selected_action()
3020 .map(|action| action.title.as_str()),
3021 Some("Action 0")
3022 );
3023 }
3024
3025 #[test]
3026 fn action_item_clone() {
3027 let item = ActionItem::new("id", "Title")
3028 .with_description("Desc")
3029 .with_tags(&["a", "b"])
3030 .with_category("Cat");
3031 let cloned = item.clone();
3032 assert_eq!(cloned.id, "id");
3033 assert_eq!(cloned.title, "Title");
3034 assert_eq!(cloned.description.as_deref(), Some("Desc"));
3035 assert_eq!(cloned.tags, vec!["a", "b"]);
3036 assert_eq!(cloned.category.as_deref(), Some("Cat"));
3037 }
3038
3039 #[test]
3040 fn action_item_debug() {
3041 let item = ActionItem::new("id", "Title");
3042 let debug = format!("{:?}", item);
3043 assert!(debug.contains("ActionItem"));
3044 assert!(debug.contains("Title"));
3045 }
3046
3047 #[test]
3048 fn palette_action_clone_and_debug() {
3049 let exec = PaletteAction::Execute("test".into());
3050 let cloned = exec.clone();
3051 assert_eq!(exec, cloned);
3052
3053 let dismiss = PaletteAction::Dismiss;
3054 let debug = format!("{:?}", dismiss);
3055 assert!(debug.contains("Dismiss"));
3056 }
3057
3058 #[test]
3059 fn match_filter_traits() {
3060 let f = MatchFilter::Fuzzy;
3062 let debug = format!("{:?}", f);
3063 assert!(debug.contains("Fuzzy"));
3064
3065 let f2 = f;
3067 assert_eq!(f, f2);
3068
3069 assert_eq!(MatchFilter::All, MatchFilter::All);
3071 assert_ne!(MatchFilter::Exact, MatchFilter::Prefix);
3072 }
3073
3074 #[test]
3075 fn match_filter_specific_allows() {
3076 assert!(MatchFilter::Prefix.allows(MatchType::Prefix));
3077 assert!(!MatchFilter::Prefix.allows(MatchType::Exact));
3078 assert!(!MatchFilter::Prefix.allows(MatchType::Substring));
3079
3080 assert!(MatchFilter::WordStart.allows(MatchType::WordStart));
3081 assert!(!MatchFilter::WordStart.allows(MatchType::Fuzzy));
3082
3083 assert!(MatchFilter::Substring.allows(MatchType::Substring));
3084 assert!(!MatchFilter::Substring.allows(MatchType::WordStart));
3085 }
3086
3087 #[test]
3088 fn palette_style_default_has_all_colors() {
3089 let style = PaletteStyle::default();
3090 assert!(style.border.fg.is_some());
3091 assert!(style.input.fg.is_some());
3092 assert!(style.item.fg.is_some());
3093 assert!(style.item_selected.fg.is_some());
3094 assert!(style.item_selected.bg.is_some());
3095 assert!(style.match_highlight.fg.is_some());
3096 assert!(style.description.fg.is_some());
3097 assert!(style.category.fg.is_some());
3098 assert!(style.hint.fg.is_some());
3099 }
3100
3101 #[test]
3102 fn palette_style_debug_and_clone() {
3103 let style = PaletteStyle::default();
3104 let debug = format!("{:?}", style);
3105 assert!(debug.contains("PaletteStyle"));
3106
3107 let cloned = style.clone();
3108 assert_eq!(cloned.border.fg, style.border.fg);
3110 }
3111
3112 #[test]
3113 fn with_style_builder() {
3114 let style = PaletteStyle::default();
3115 let palette = CommandPalette::new().with_style(style);
3116 assert!(!palette.is_visible());
3118 }
3119
3120 #[test]
3121 fn command_palette_debug() {
3122 let mut palette = CommandPalette::new();
3123 palette.register("Alpha", None, &[]);
3124 let debug = format!("{:?}", palette);
3125 assert!(debug.contains("CommandPalette"));
3126 }
3127
3128 #[test]
3129 fn unrecognized_key_returns_none() {
3130 let mut palette = CommandPalette::new();
3131 palette.open();
3132
3133 let tab = Event::Key(KeyEvent {
3134 code: KeyCode::Tab,
3135 modifiers: Modifiers::empty(),
3136 kind: KeyEventKind::Press,
3137 });
3138 let result = palette.handle_event(&tab);
3139 assert!(result.is_none());
3140 }
3141
3142 #[test]
3143 fn ctrl_p_when_visible_does_not_reopen() {
3144 let mut palette = CommandPalette::new();
3145 palette.register("Alpha", None, &[]);
3146 palette.open();
3147 palette.set_query("test");
3148
3149 let ctrl_p = Event::Key(KeyEvent {
3151 code: KeyCode::Char('p'),
3152 modifiers: Modifiers::CTRL,
3153 kind: KeyEventKind::Press,
3154 });
3155 let _ = palette.handle_event(&ctrl_p);
3156 assert!(palette.is_visible());
3158 }
3159
3160 #[test]
3161 fn close_clears_query_and_results() {
3162 let mut palette = CommandPalette::new();
3163 palette.register("Alpha", None, &[]);
3164 palette.open();
3165 palette.set_query("alpha");
3166 assert!(!palette.query().is_empty());
3167 assert!(palette.result_count() > 0);
3168
3169 palette.close();
3170 assert!(!palette.is_visible());
3171 assert_eq!(palette.query(), "");
3172 assert_eq!(palette.result_count(), 0);
3173 }
3174
3175 #[test]
3176 fn render_cursor_position_set() {
3177 use ftui_render::grapheme_pool::GraphemePool;
3178
3179 let mut palette = CommandPalette::new();
3180 palette.register("Alpha", None, &[]);
3181 palette.open();
3182
3183 let area = Rect::from_size(60, 15);
3184 let mut pool = GraphemePool::new();
3185 let mut frame = Frame::new(60, 15, &mut pool);
3186 palette.render(area, &mut frame);
3187
3188 assert!(frame.cursor_position.is_some());
3189 assert!(frame.cursor_visible);
3190 }
3191
3192 #[test]
3193 fn render_uses_rounded_corners_for_overlay_border() {
3194 use ftui_render::grapheme_pool::GraphemePool;
3195
3196 let mut palette = CommandPalette::new();
3197 palette.register("Alpha", None, &[]);
3198 palette.open();
3199
3200 let area = Rect::from_size(60, 15);
3201 let mut pool = GraphemePool::new();
3202 let mut frame = Frame::new(60, 15, &mut pool);
3203 palette.render(area, &mut frame);
3204
3205 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
3206 let result_rows = palette.result_count().min(palette.max_visible);
3207 let palette_height = (result_rows as u16 + 3)
3208 .max(5)
3209 .min(area.height.saturating_sub(2));
3210 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
3211 let palette_y = area.y + area.height / 6;
3212 let right = palette_x + palette_width - 1;
3213 let bottom = palette_y + palette_height - 1;
3214
3215 assert_eq!(
3216 frame
3217 .buffer
3218 .get(palette_x, palette_y)
3219 .and_then(|c| c.content.as_char()),
3220 Some('╭')
3221 );
3222 assert_eq!(
3223 frame
3224 .buffer
3225 .get(right, palette_y)
3226 .and_then(|c| c.content.as_char()),
3227 Some('╮')
3228 );
3229 assert_eq!(
3230 frame
3231 .buffer
3232 .get(palette_x, bottom)
3233 .and_then(|c| c.content.as_char()),
3234 Some('╰')
3235 );
3236 assert_eq!(
3237 frame
3238 .buffer
3239 .get(right, bottom)
3240 .and_then(|c| c.content.as_char()),
3241 Some('╯')
3242 );
3243 }
3244
3245 #[test]
3246 fn render_many_items_with_scroll() {
3247 use ftui_render::grapheme_pool::GraphemePool;
3248
3249 let mut palette = CommandPalette::new().with_max_visible(3);
3250 for i in 0..20 {
3251 palette.register(format!("Action {i}"), None, &[]);
3252 }
3253 palette.open();
3254
3255 let end = Event::Key(KeyEvent {
3257 code: KeyCode::End,
3258 modifiers: Modifiers::empty(),
3259 kind: KeyEventKind::Press,
3260 });
3261 let _ = palette.handle_event(&end);
3262
3263 let area = Rect::from_size(60, 15);
3264 let mut pool = GraphemePool::new();
3265 let mut frame = Frame::new(60, 15, &mut pool);
3266 palette.render(area, &mut frame);
3268 assert!(frame.cursor_position.is_some());
3269 }
3270}
3271mod property_tests;