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