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 emit_palette_opened(self.actions.len(), self.filtered.len());
600 }
601
602 pub fn close(&mut self) {
604 self.close_with_reason(PaletteCloseReason::Programmatic);
605 }
606
607 pub fn toggle(&mut self) {
609 if self.visible {
610 self.close_with_reason(PaletteCloseReason::Toggle);
611 } else {
612 self.open();
613 }
614 }
615
616 #[inline]
618 pub fn is_visible(&self) -> bool {
619 self.visible
620 }
621
622 pub fn query(&self) -> &str {
626 &self.query
627 }
628
629 pub fn set_query(&mut self, query: impl Into<String>) {
631 self.query = query.into();
632 self.cursor = self.query.len();
633 self.selected = 0;
634 self.scroll_offset = 0;
635 self.scorer.invalidate();
636 self.update_filtered(false);
637 }
638
639 pub fn result_count(&self) -> usize {
641 self.filtered.len()
642 }
643
644 #[inline]
646 pub fn selected_index(&self) -> usize {
647 self.selected
648 }
649
650 #[must_use = "use the selected action (if any)"]
652 pub fn selected_action(&self) -> Option<&ActionItem> {
653 self.filtered
654 .get(self.selected)
655 .map(|si| &self.actions[si.action_index])
656 }
657
658 #[must_use = "use the selected match (if any)"]
660 pub fn selected_match(&self) -> Option<PaletteMatch<'_>> {
661 self.filtered.get(self.selected).map(|si| PaletteMatch {
662 action: &self.actions[si.action_index],
663 result: &si.result,
664 })
665 }
666
667 pub fn results(&self) -> impl Iterator<Item = PaletteMatch<'_>> {
669 self.filtered.iter().map(|si| PaletteMatch {
670 action: &self.actions[si.action_index],
671 result: &si.result,
672 })
673 }
674
675 pub fn set_match_filter(&mut self, filter: MatchFilter) {
677 if self.match_filter == filter {
678 return;
679 }
680 self.match_filter = filter;
681 self.selected = 0;
682 self.scroll_offset = 0;
683 self.update_filtered(false);
684 }
685
686 pub fn scorer_stats(&self) -> &IncrementalStats {
688 self.scorer.stats()
689 }
690
691 #[must_use = "use the returned action (if any) to execute or dismiss"]
699 pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
700 if !self.visible {
701 if let Event::Key(KeyEvent {
703 code: KeyCode::Char('p'),
704 modifiers,
705 kind: KeyEventKind::Press,
706 }) = event
707 && modifiers.contains(Modifiers::CTRL)
708 {
709 self.open();
710 }
711 return None;
712 }
713
714 match event {
715 Event::Key(KeyEvent {
716 code,
717 modifiers,
718 kind: KeyEventKind::Press,
719 }) => self.handle_key(*code, *modifiers),
720 _ => None,
721 }
722 }
723
724 fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Option<PaletteAction> {
726 match code {
727 KeyCode::Escape => {
728 self.close_with_reason(PaletteCloseReason::Dismiss);
729 return Some(PaletteAction::Dismiss);
730 }
731
732 KeyCode::Enter => {
733 if let Some(si) = self.filtered.get(self.selected) {
734 let id = self.actions[si.action_index].id.clone();
735 #[cfg(feature = "tracing")]
736 {
737 let latency_ms = self.opened_at.map(|start| start.elapsed().as_millis());
738 emit_palette_action_executed(&id, latency_ms);
739 }
740 self.close_with_reason(PaletteCloseReason::Execute);
741 return Some(PaletteAction::Execute(id));
742 }
743 }
744
745 KeyCode::Up if self.selected > 0 => {
746 self.selected -= 1;
747 self.adjust_scroll();
748 }
749
750 KeyCode::Down
751 if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 =>
752 {
753 self.selected += 1;
754 self.adjust_scroll();
755 }
756
757 KeyCode::PageUp => {
758 self.selected = self.selected.saturating_sub(self.max_visible);
759 self.adjust_scroll();
760 }
761
762 KeyCode::PageDown if !self.filtered.is_empty() => {
763 self.selected = (self.selected + self.max_visible).min(self.filtered.len() - 1);
764 self.adjust_scroll();
765 }
766
767 KeyCode::Home => {
768 self.selected = 0;
769 self.scroll_offset = 0;
770 }
771
772 KeyCode::End if !self.filtered.is_empty() => {
773 self.selected = self.filtered.len() - 1;
774 self.adjust_scroll();
775 }
776
777 KeyCode::Backspace if self.cursor > 0 => {
778 let prev = self.query[..self.cursor]
780 .char_indices()
781 .next_back()
782 .map(|(i, _)| i)
783 .unwrap_or(0);
784 self.query.drain(prev..self.cursor);
785 self.cursor = prev;
786 self.selected = 0;
787 self.scroll_offset = 0;
788 self.update_filtered(true);
789 }
790
791 KeyCode::Delete if self.cursor < self.query.len() => {
792 let next = self.query[self.cursor..]
794 .char_indices()
795 .nth(1)
796 .map(|(i, _)| self.cursor + i)
797 .unwrap_or(self.query.len());
798 self.query.drain(self.cursor..next);
799 self.selected = 0;
800 self.scroll_offset = 0;
801 self.update_filtered(true);
802 }
803
804 KeyCode::Left if self.cursor > 0 => {
805 self.cursor = self.query[..self.cursor]
807 .char_indices()
808 .next_back()
809 .map(|(i, _)| i)
810 .unwrap_or(0);
811 }
812
813 KeyCode::Right if self.cursor < self.query.len() => {
814 self.cursor = self.query[self.cursor..]
816 .char_indices()
817 .nth(1)
818 .map(|(i, _)| self.cursor + i)
819 .unwrap_or(self.query.len());
820 }
821
822 KeyCode::Char(c) => {
823 if modifiers.contains(Modifiers::CTRL) {
824 if c == 'a' {
826 self.cursor = 0;
827 }
828 if c == 'e' {
830 self.cursor = self.query.len();
831 }
832 if c == 'u' {
834 self.query.clear();
835 self.cursor = 0;
836 self.selected = 0;
837 self.scroll_offset = 0;
838 self.update_filtered(true);
839 }
840 } else {
841 self.query.insert(self.cursor, c);
843 self.cursor += c.len_utf8();
844 self.selected = 0;
845 self.scroll_offset = 0;
846 self.update_filtered(true);
847 }
848 }
849
850 _ => {}
851 }
852
853 None
854 }
855
856 fn update_filtered(&mut self, _emit_telemetry: bool) {
858 #[cfg(feature = "tracing")]
859 let start = _emit_telemetry.then(Instant::now);
863
864 if self.titles_cache.len() != self.actions.len()
865 || self.titles_lower.len() != self.actions.len()
866 || self.titles_word_starts.len() != self.actions.len()
867 {
868 self.rebuild_title_cache();
869 }
870
871 let results = self.scorer.score_corpus_with_lowered_and_words(
872 &self.query,
873 &self.titles_cache,
874 &self.titles_lower,
875 &self.titles_word_starts,
876 Some(self.generation),
877 );
878
879 self.filtered = results
880 .into_iter()
881 .filter(|(_, result)| self.match_filter.allows(result.match_type))
882 .map(|(idx, result)| ScoredItem {
883 action_index: idx,
884 result,
885 })
886 .collect();
887
888 if !self.filtered.is_empty() {
890 self.selected = self.selected.min(self.filtered.len() - 1);
891 self.adjust_scroll();
892 } else {
893 self.selected = 0;
894 self.scroll_offset = 0;
895 }
896
897 #[cfg(feature = "tracing")]
898 if let Some(start) = start {
899 emit_palette_query_updated(
900 &self.query,
901 self.filtered.len(),
902 start.elapsed().as_millis(),
903 );
904 }
905 }
906
907 fn close_with_reason(&mut self, _reason: PaletteCloseReason) {
908 self.visible = false;
909 self.query.clear();
910 self.cursor = 0;
911 self.filtered.clear();
912 #[cfg(feature = "tracing")]
913 {
914 self.opened_at = None;
915 emit_palette_closed(_reason);
916 }
917 }
918
919 fn adjust_scroll(&mut self) {
921 if self.selected < self.scroll_offset {
922 self.scroll_offset = self.selected;
923 } else if self.selected >= self.scroll_offset.saturating_add(self.max_visible) {
924 self.scroll_offset = self
925 .selected
926 .saturating_sub(self.max_visible.saturating_sub(1));
927 }
928 }
929}
930
931impl Widget for CommandPalette {
936 fn render(&self, area: Rect, frame: &mut Frame) {
937 if !self.visible || area.width < 10 || area.height < 5 {
938 return;
939 }
940
941 let palette_area = if self.fill_area {
942 area
944 } else {
945 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
947 let result_rows = self.filtered.len().min(self.max_visible);
948 let palette_height = (result_rows as u16 + 3)
950 .max(5)
951 .min(area.height.saturating_sub(2));
952 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
953 let palette_y = area.y + area.height / 6; Rect::new(palette_x, palette_y, palette_width, palette_height)
955 };
956
957 self.clear_area(palette_area, frame);
959
960 self.draw_border(palette_area, frame);
962
963 let input_area = Rect::new(
965 palette_area.x + 2,
966 palette_area.y + 1,
967 palette_area.width.saturating_sub(4),
968 1,
969 );
970 self.draw_query_input(input_area, frame);
971
972 let results_y = palette_area.y + 2;
974 let results_height = palette_area.height.saturating_sub(3);
975 let results_area = Rect::new(
976 palette_area.x + 1,
977 results_y,
978 palette_area.width.saturating_sub(2),
979 results_height,
980 );
981 self.draw_results(results_area, frame);
982
983 let cursor_visual_pos = display_width(&self.query[..self.cursor.min(self.query.len())]);
987 let cursor_x = input_area.x + cursor_visual_pos.min(input_area.width as usize) as u16;
988 frame.cursor_position = Some((cursor_x, input_area.y));
989 frame.cursor_visible = true;
990 }
991
992 fn is_essential(&self) -> bool {
993 true
994 }
995}
996
997impl CommandPalette {
998 fn palette_background(&self) -> PackedRgba {
1003 self.style
1004 .border
1005 .bg
1006 .or(self.style.input.bg)
1007 .or(self.style.item.bg)
1008 .or(self.style.hint.bg)
1009 .or(self.style.item_selected.bg)
1010 .unwrap_or(PackedRgba::rgb(30, 30, 40))
1011 }
1012
1013 fn clear_area(&self, area: Rect, frame: &mut Frame) {
1015 let bg = self.palette_background();
1016 for y in area.y..area.bottom() {
1017 for x in area.x..area.right() {
1018 if let Some(cell) = frame.buffer.get_mut(x, y) {
1019 *cell = Cell::from_char(' ');
1020 cell.bg = bg;
1021 }
1022 }
1023 }
1024 }
1025
1026 fn draw_border(&self, area: Rect, frame: &mut Frame) {
1028 let border_fg = self
1029 .style
1030 .border
1031 .fg
1032 .unwrap_or(PackedRgba::rgb(100, 100, 120));
1033 let bg = self.palette_background();
1034
1035 let mut cell = Cell::from_char('╭');
1037 cell.fg = border_fg;
1038 cell.bg = bg;
1039 frame.buffer.set_fast(area.x, area.y, cell);
1040
1041 for x in (area.x + 1)..area.right().saturating_sub(1) {
1042 let mut cell = Cell::from_char('─');
1043 cell.fg = border_fg;
1044 cell.bg = bg;
1045 frame.buffer.set_fast(x, area.y, cell);
1046 }
1047 if area.width > 1 {
1048 let mut cell = Cell::from_char('╮');
1049 cell.fg = border_fg;
1050 cell.bg = bg;
1051 frame.buffer.set_fast(area.right() - 1, area.y, cell);
1052 }
1053
1054 let title = &self.title;
1056 let title_width = display_width(title).min(area.width as usize);
1057 let title_x = area.x + (area.width.saturating_sub(title_width as u16)) / 2;
1058 let title_style = Style::new().fg(PackedRgba::rgb(200, 200, 220)).bg(bg);
1059 crate::draw_text_span(frame, title_x, area.y, title, title_style, area.right());
1060
1061 for y in (area.y + 1)..area.bottom().saturating_sub(1) {
1063 let mut cell_l = Cell::from_char('│');
1064 cell_l.fg = border_fg;
1065 cell_l.bg = bg;
1066 frame.buffer.set_fast(area.x, y, cell_l);
1067
1068 if area.width > 1 {
1069 let mut cell_r = Cell::from_char('│');
1070 cell_r.fg = border_fg;
1071 cell_r.bg = bg;
1072 frame.buffer.set_fast(area.right() - 1, y, cell_r);
1073 }
1074 }
1075
1076 if area.height > 1 {
1078 let by = area.bottom() - 1;
1079 let mut cell_bl = Cell::from_char('╰');
1080 cell_bl.fg = border_fg;
1081 cell_bl.bg = bg;
1082 frame.buffer.set_fast(area.x, by, cell_bl);
1083
1084 for x in (area.x + 1)..area.right().saturating_sub(1) {
1085 let mut cell = Cell::from_char('─');
1086 cell.fg = border_fg;
1087 cell.bg = bg;
1088 frame.buffer.set_fast(x, by, cell);
1089 }
1090 if area.width > 1 {
1091 let mut cell_br = Cell::from_char('╯');
1092 cell_br.fg = border_fg;
1093 cell_br.bg = bg;
1094 frame.buffer.set_fast(area.right() - 1, by, cell_br);
1095 }
1096 }
1097 }
1098
1099 fn draw_query_input(&self, area: Rect, frame: &mut Frame) {
1101 let input_fg = self
1102 .style
1103 .input
1104 .fg
1105 .unwrap_or(PackedRgba::rgb(220, 220, 230));
1106 let bg = self.palette_background();
1107 let prompt_fg = PackedRgba::rgb(100, 180, 255);
1108
1109 if let Some(cell) = frame.buffer.get_mut(area.x.saturating_sub(1), area.y) {
1111 cell.content = CellContent::from_char('>');
1112 cell.fg = prompt_fg;
1113 cell.bg = bg;
1114 }
1115
1116 if self.query.is_empty() {
1118 let hint = "Type to search...";
1120 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1121 for (i, ch) in hint.chars().enumerate() {
1122 let x = area.x + i as u16;
1123 if x >= area.right() {
1124 break;
1125 }
1126 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1127 cell.content = CellContent::from_char(ch);
1128 cell.fg = hint_fg;
1129 cell.bg = bg;
1130 }
1131 }
1132 } else {
1133 let mut col = area.x;
1135 for grapheme in graphemes(&self.query) {
1136 let w = grapheme_width(grapheme);
1137 if w == 0 {
1138 continue;
1139 }
1140 if col >= area.right() {
1141 break;
1142 }
1143 if col.saturating_add(w as u16) > area.right() {
1144 break;
1145 }
1146 let content = if w > 1 || grapheme.chars().count() > 1 {
1147 let id = frame.intern_with_width(grapheme, w as u8);
1148 CellContent::from_grapheme(id)
1149 } else if let Some(ch) = grapheme.chars().next() {
1150 CellContent::from_char(ch)
1151 } else {
1152 continue;
1153 };
1154 let mut cell = Cell::new(content);
1155 cell.fg = input_fg;
1156 cell.bg = bg;
1157 frame.buffer.set_fast(col, area.y, cell);
1158 col = col.saturating_add(w as u16);
1159 }
1160 }
1161 }
1162
1163 fn draw_results(&self, area: Rect, frame: &mut Frame) {
1165 if self.filtered.is_empty() {
1166 let msg = if self.query.is_empty() {
1168 "No actions registered"
1169 } else {
1170 "No results"
1171 };
1172 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1173 let bg = self.palette_background();
1174 for (i, ch) in msg.chars().enumerate() {
1175 let x = area.x + 1 + i as u16;
1176 if x >= area.right() {
1177 break;
1178 }
1179 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1180 cell.content = CellContent::from_char(ch);
1181 cell.fg = hint_fg;
1182 cell.bg = bg;
1183 }
1184 }
1185 return;
1186 }
1187
1188 let item_fg = self.style.item.fg.unwrap_or(PackedRgba::rgb(180, 180, 190));
1189 let selected_fg = self
1190 .style
1191 .item_selected
1192 .fg
1193 .unwrap_or(PackedRgba::rgb(255, 255, 255));
1194 let selected_bg = self
1195 .style
1196 .item_selected
1197 .bg
1198 .unwrap_or(PackedRgba::rgb(60, 60, 80));
1199 let highlight_fg = self
1200 .style
1201 .match_highlight
1202 .fg
1203 .unwrap_or(PackedRgba::rgb(255, 200, 50));
1204 let desc_fg = self
1205 .style
1206 .description
1207 .fg
1208 .unwrap_or(PackedRgba::rgb(120, 120, 140));
1209 let cat_fg = self
1210 .style
1211 .category
1212 .fg
1213 .unwrap_or(PackedRgba::rgb(100, 180, 255));
1214 let bg = self.palette_background();
1215
1216 let visible_end = (self.scroll_offset + area.height as usize).min(self.filtered.len());
1217
1218 for (row_idx, si) in self.filtered[self.scroll_offset..visible_end]
1219 .iter()
1220 .enumerate()
1221 {
1222 let y = area.y + row_idx as u16;
1223 if y >= area.bottom() {
1224 break;
1225 }
1226
1227 let action = &self.actions[si.action_index];
1228 let is_selected = (self.scroll_offset + row_idx) == self.selected;
1229
1230 let row_fg = if is_selected { selected_fg } else { item_fg };
1231 let row_bg = if is_selected { selected_bg } else { bg };
1232
1233 let row_attrs = if is_selected {
1235 CellAttrs::new(CellStyleFlags::BOLD, 0)
1236 } else {
1237 CellAttrs::default()
1238 };
1239
1240 for x in area.x..area.right() {
1242 if let Some(cell) = frame.buffer.get_mut(x, y) {
1243 cell.content = CellContent::from_char(' ');
1244 cell.fg = row_fg;
1245 cell.bg = row_bg;
1246 cell.attrs = row_attrs;
1247 }
1248 }
1249
1250 let mut col = area.x;
1252 if is_selected && let Some(cell) = frame.buffer.get_mut(col, y) {
1253 cell.content = CellContent::from_char('>');
1254 cell.fg = highlight_fg;
1255 cell.bg = row_bg;
1256 cell.attrs = CellAttrs::new(CellStyleFlags::BOLD, 0);
1257 }
1258 col += 2;
1259
1260 if let Some(ref cat) = action.category {
1262 let badge = format!("[{}] ", cat);
1263 for grapheme in graphemes(&badge) {
1264 let w = grapheme_width(grapheme);
1265 if w == 0 {
1266 continue;
1267 }
1268 if col >= area.right() || col.saturating_add(w as u16) > area.right() {
1269 break;
1270 }
1271 let content = if w > 1 || grapheme.chars().count() > 1 {
1272 let id = frame.intern_with_width(grapheme, w as u8);
1273 CellContent::from_grapheme(id)
1274 } else if let Some(ch) = grapheme.chars().next() {
1275 CellContent::from_char(ch)
1276 } else {
1277 continue;
1278 };
1279 let mut cell = Cell::new(content);
1280 cell.fg = cat_fg;
1281 cell.bg = row_bg;
1282 cell.attrs = row_attrs;
1283 frame.buffer.set_fast(col, y, cell);
1284 col = col.saturating_add(w as u16);
1285 }
1286 }
1287
1288 let title_max_width = area.right().saturating_sub(col) as usize;
1290 let title_width = display_width(action.title.as_str());
1291 let needs_ellipsis = title_width > title_max_width && title_max_width > 3;
1292 let title_display_width = if needs_ellipsis {
1293 title_max_width.saturating_sub(1) } else {
1295 title_max_width
1296 };
1297
1298 let mut title_used_width = 0usize;
1299 let mut char_idx = 0usize;
1300 let mut match_cursor = 0usize;
1301 let match_positions = &si.result.match_positions;
1302 for grapheme in graphemes(action.title.as_str()) {
1303 let g_chars = grapheme.chars().count();
1304 let char_end = char_idx + g_chars;
1305 while match_cursor < match_positions.len()
1306 && match_positions[match_cursor] < char_idx
1307 {
1308 match_cursor += 1;
1309 }
1310 let is_match = match_cursor < match_positions.len()
1311 && match_positions[match_cursor] < char_end;
1312
1313 let w = grapheme_width(grapheme);
1314 if w == 0 {
1315 char_idx = char_end;
1316 continue;
1317 }
1318 if title_used_width + w > title_display_width || col >= area.right() {
1319 break;
1320 }
1321 if col.saturating_add(w as u16) > area.right() {
1322 break;
1323 }
1324
1325 let content = if w > 1 || grapheme.chars().count() > 1 {
1326 let id = frame.intern_with_width(grapheme, w as u8);
1327 CellContent::from_grapheme(id)
1328 } else if let Some(ch) = grapheme.chars().next() {
1329 CellContent::from_char(ch)
1330 } else {
1331 char_idx = char_end;
1332 continue;
1333 };
1334
1335 let mut cell = Cell::new(content);
1336 cell.fg = if is_match { highlight_fg } else { row_fg };
1337 cell.bg = row_bg;
1338 cell.attrs = row_attrs;
1339 frame.buffer.set_fast(col, y, cell);
1340
1341 col = col.saturating_add(w as u16);
1342 title_used_width += w;
1343 char_idx = char_end;
1344 }
1345
1346 if needs_ellipsis && col < area.right() {
1348 if let Some(cell) = frame.buffer.get_mut(col, y) {
1349 cell.content = CellContent::from_char('\u{2026}'); cell.fg = row_fg;
1351 cell.bg = row_bg;
1352 cell.attrs = row_attrs;
1353 }
1354 col += 1;
1355 }
1356
1357 if let Some(ref desc) = action.description {
1359 col += 2; let max_desc_width = area.right().saturating_sub(col) as usize;
1361 if max_desc_width > 5 {
1362 let desc_width = display_width(desc.as_str());
1363 let desc_needs_ellipsis = desc_width > max_desc_width && max_desc_width > 3;
1364 let desc_display_width = if desc_needs_ellipsis {
1365 max_desc_width.saturating_sub(1)
1366 } else {
1367 max_desc_width
1368 };
1369
1370 let mut desc_used_width = 0usize;
1371 for grapheme in graphemes(desc.as_str()) {
1372 let w = grapheme_width(grapheme);
1373 if w == 0 {
1374 continue;
1375 }
1376 if desc_used_width + w > desc_display_width || col >= area.right() {
1377 break;
1378 }
1379 if col.saturating_add(w as u16) > area.right() {
1380 break;
1381 }
1382 let content = if w > 1 || grapheme.chars().count() > 1 {
1383 let id = frame.intern_with_width(grapheme, w as u8);
1384 CellContent::from_grapheme(id)
1385 } else if let Some(ch) = grapheme.chars().next() {
1386 CellContent::from_char(ch)
1387 } else {
1388 continue;
1389 };
1390 let mut cell = Cell::new(content);
1391 cell.fg = desc_fg;
1392 cell.bg = row_bg;
1393 cell.attrs = row_attrs;
1394 frame.buffer.set_fast(col, y, cell);
1395 col = col.saturating_add(w as u16);
1396 desc_used_width += w;
1397 }
1398
1399 if desc_needs_ellipsis
1400 && col < area.right()
1401 && let Some(cell) = frame.buffer.get_mut(col, y)
1402 {
1403 cell.content = CellContent::from_char('\u{2026}');
1404 cell.fg = desc_fg;
1405 cell.bg = row_bg;
1406 cell.attrs = row_attrs;
1407 }
1408 }
1409 }
1410 }
1411 }
1412}
1413
1414#[cfg(test)]
1419mod widget_tests {
1420 use super::*;
1421
1422 #[test]
1423 fn new_palette_is_hidden() {
1424 let palette = CommandPalette::new();
1425 assert!(!palette.is_visible());
1426 assert_eq!(palette.action_count(), 0);
1427 }
1428
1429 #[test]
1430 fn register_actions() {
1431 let mut palette = CommandPalette::new();
1432 palette.register("Open File", Some("Open a file"), &["file"]);
1433 palette.register("Save File", None, &[]);
1434 assert_eq!(palette.action_count(), 2);
1435 }
1436
1437 #[test]
1438 fn open_shows_all_actions() {
1439 let mut palette = CommandPalette::new();
1440 palette.register("Open File", None, &[]);
1441 palette.register("Save File", None, &[]);
1442 palette.register("Close Tab", None, &[]);
1443 palette.open();
1444 assert!(palette.is_visible());
1445 assert_eq!(palette.result_count(), 3);
1446 }
1447
1448 #[test]
1449 fn close_hides_palette() {
1450 let mut palette = CommandPalette::new();
1451 palette.open();
1452 assert!(palette.is_visible());
1453 palette.close();
1454 assert!(!palette.is_visible());
1455 }
1456
1457 #[test]
1458 fn toggle_visibility() {
1459 let mut palette = CommandPalette::new();
1460 palette.toggle();
1461 assert!(palette.is_visible());
1462 palette.toggle();
1463 assert!(!palette.is_visible());
1464 }
1465
1466 #[test]
1467 fn typing_filters_results() {
1468 let mut palette = CommandPalette::new();
1469 palette.register("Open File", None, &[]);
1470 palette.register("Save File", None, &[]);
1471 palette.register("Git: Commit", None, &[]);
1472 palette.open();
1473 assert_eq!(palette.result_count(), 3);
1474
1475 let g = Event::Key(KeyEvent {
1477 code: KeyCode::Char('g'),
1478 modifiers: Modifiers::empty(),
1479 kind: KeyEventKind::Press,
1480 });
1481 let i = Event::Key(KeyEvent {
1482 code: KeyCode::Char('i'),
1483 modifiers: Modifiers::empty(),
1484 kind: KeyEventKind::Press,
1485 });
1486 let t = Event::Key(KeyEvent {
1487 code: KeyCode::Char('t'),
1488 modifiers: Modifiers::empty(),
1489 kind: KeyEventKind::Press,
1490 });
1491
1492 let _ = palette.handle_event(&g);
1493 let _ = palette.handle_event(&i);
1494 let _ = palette.handle_event(&t);
1495
1496 assert_eq!(palette.query(), "git");
1497 assert!(palette.result_count() >= 1);
1499 }
1500
1501 #[test]
1502 fn backspace_removes_character() {
1503 let mut palette = CommandPalette::new();
1504 palette.register("Open File", None, &[]);
1505 palette.open();
1506
1507 let o = Event::Key(KeyEvent {
1508 code: KeyCode::Char('o'),
1509 modifiers: Modifiers::empty(),
1510 kind: KeyEventKind::Press,
1511 });
1512 let bs = Event::Key(KeyEvent {
1513 code: KeyCode::Backspace,
1514 modifiers: Modifiers::empty(),
1515 kind: KeyEventKind::Press,
1516 });
1517
1518 let _ = palette.handle_event(&o);
1519 assert_eq!(palette.query(), "o");
1520 let _ = palette.handle_event(&bs);
1521 assert_eq!(palette.query(), "");
1522 }
1523
1524 #[test]
1525 fn esc_dismisses_palette() {
1526 let mut palette = CommandPalette::new();
1527 palette.open();
1528
1529 let esc = Event::Key(KeyEvent {
1530 code: KeyCode::Escape,
1531 modifiers: Modifiers::empty(),
1532 kind: KeyEventKind::Press,
1533 });
1534
1535 let result = palette.handle_event(&esc);
1536 assert_eq!(result, Some(PaletteAction::Dismiss));
1537 assert!(!palette.is_visible());
1538 }
1539
1540 #[test]
1541 fn enter_executes_selected() {
1542 let mut palette = CommandPalette::new();
1543 palette.register("Open File", None, &[]);
1544 palette.open();
1545
1546 let enter = Event::Key(KeyEvent {
1547 code: KeyCode::Enter,
1548 modifiers: Modifiers::empty(),
1549 kind: KeyEventKind::Press,
1550 });
1551
1552 let result = palette.handle_event(&enter);
1553 assert_eq!(result, Some(PaletteAction::Execute("open_file".into())));
1554 }
1555
1556 #[test]
1557 fn arrow_keys_navigate() {
1558 let mut palette = CommandPalette::new();
1559 palette.register("A", None, &[]);
1560 palette.register("B", None, &[]);
1561 palette.register("C", None, &[]);
1562 palette.open();
1563
1564 assert_eq!(palette.selected_index(), 0);
1565
1566 let down = Event::Key(KeyEvent {
1567 code: KeyCode::Down,
1568 modifiers: Modifiers::empty(),
1569 kind: KeyEventKind::Press,
1570 });
1571 let up = Event::Key(KeyEvent {
1572 code: KeyCode::Up,
1573 modifiers: Modifiers::empty(),
1574 kind: KeyEventKind::Press,
1575 });
1576
1577 let _ = palette.handle_event(&down);
1578 assert_eq!(palette.selected_index(), 1);
1579 let _ = palette.handle_event(&down);
1580 assert_eq!(palette.selected_index(), 2);
1581 let _ = palette.handle_event(&down);
1583 assert_eq!(palette.selected_index(), 2);
1584
1585 let _ = palette.handle_event(&up);
1586 assert_eq!(palette.selected_index(), 1);
1587 let _ = palette.handle_event(&up);
1588 assert_eq!(palette.selected_index(), 0);
1589 let _ = palette.handle_event(&up);
1591 assert_eq!(palette.selected_index(), 0);
1592 }
1593
1594 #[test]
1595 fn home_end_navigation() {
1596 let mut palette = CommandPalette::new();
1597 for i in 0..20 {
1598 palette.register(format!("Action {}", i), None, &[]);
1599 }
1600 palette.open();
1601
1602 let end = Event::Key(KeyEvent {
1603 code: KeyCode::End,
1604 modifiers: Modifiers::empty(),
1605 kind: KeyEventKind::Press,
1606 });
1607 let home = Event::Key(KeyEvent {
1608 code: KeyCode::Home,
1609 modifiers: Modifiers::empty(),
1610 kind: KeyEventKind::Press,
1611 });
1612
1613 let _ = palette.handle_event(&end);
1614 assert_eq!(palette.selected_index(), 19);
1615
1616 let _ = palette.handle_event(&home);
1617 assert_eq!(palette.selected_index(), 0);
1618 }
1619
1620 #[test]
1621 fn ctrl_u_clears_query() {
1622 let mut palette = CommandPalette::new();
1623 palette.register("Open File", None, &[]);
1624 palette.open();
1625
1626 let o = Event::Key(KeyEvent {
1627 code: KeyCode::Char('o'),
1628 modifiers: Modifiers::empty(),
1629 kind: KeyEventKind::Press,
1630 });
1631 let _ = palette.handle_event(&o);
1632 assert_eq!(palette.query(), "o");
1633
1634 let ctrl_u = Event::Key(KeyEvent {
1635 code: KeyCode::Char('u'),
1636 modifiers: Modifiers::CTRL,
1637 kind: KeyEventKind::Press,
1638 });
1639 let _ = palette.handle_event(&ctrl_u);
1640 assert_eq!(palette.query(), "");
1641 }
1642
1643 #[test]
1644 fn ctrl_p_opens_palette() {
1645 let mut palette = CommandPalette::new();
1646 assert!(!palette.is_visible());
1647
1648 let ctrl_p = Event::Key(KeyEvent {
1649 code: KeyCode::Char('p'),
1650 modifiers: Modifiers::CTRL,
1651 kind: KeyEventKind::Press,
1652 });
1653 let _ = palette.handle_event(&ctrl_p);
1654 assert!(palette.is_visible());
1655 }
1656
1657 #[test]
1658 fn selected_action_returns_correct_item() {
1659 let mut palette = CommandPalette::new();
1660 palette.register("Alpha", None, &[]);
1661 palette.register("Beta", None, &[]);
1662 palette.open();
1663
1664 let action = palette.selected_action().unwrap();
1665 assert!(!action.title.is_empty());
1667 }
1668
1669 #[test]
1670 fn register_action_item_directly() {
1671 let mut palette = CommandPalette::new();
1672 let item = ActionItem::new("custom_id", "Custom Action")
1673 .with_description("A custom action")
1674 .with_tags(&["custom", "test"])
1675 .with_category("Testing");
1676
1677 palette.register_action(item);
1678 assert_eq!(palette.action_count(), 1);
1679 }
1680
1681 #[test]
1682 fn register_refreshes_visible_filtered_results() {
1683 let mut palette = CommandPalette::new();
1684 palette.register("Alpha", None, &[]);
1685 palette.open();
1686 palette.set_query("Beta");
1687 assert!(palette.selected_action().is_none());
1688
1689 palette.register("Beta", None, &[]);
1690
1691 assert_eq!(palette.result_count(), 1);
1692 assert_eq!(
1693 palette
1694 .selected_action()
1695 .map(|action| action.title.as_str()),
1696 Some("Beta")
1697 );
1698 }
1699
1700 #[test]
1701 fn register_action_refreshes_visible_filtered_results() {
1702 let mut palette = CommandPalette::new();
1703 palette.register("Alpha", None, &[]);
1704 palette.open();
1705 palette.set_query("Beta");
1706 assert!(palette.selected_action().is_none());
1707
1708 palette.register_action(ActionItem::new("beta", "Beta"));
1709
1710 assert_eq!(palette.result_count(), 1);
1711 assert_eq!(
1712 palette
1713 .selected_action()
1714 .map(|action| action.title.as_str()),
1715 Some("Beta")
1716 );
1717 }
1718
1719 #[test]
1720 fn replace_actions_refreshes_results() {
1721 let mut palette = CommandPalette::new();
1722 palette.register("Alpha", None, &[]);
1723 palette.register("Beta", None, &[]);
1724 palette.open();
1725 palette.set_query("Beta");
1726 assert_eq!(
1727 palette.selected_action().map(|a| a.title.as_str()),
1728 Some("Beta")
1729 );
1730
1731 let actions = vec![
1732 ActionItem::new("gamma", "Gamma"),
1733 ActionItem::new("delta", "Delta"),
1734 ];
1735 palette.replace_actions(actions);
1736 palette.set_query("Delta");
1737 assert_eq!(
1738 palette.selected_action().map(|a| a.title.as_str()),
1739 Some("Delta")
1740 );
1741 }
1742
1743 #[test]
1744 fn clear_actions_resets_results() {
1745 let mut palette = CommandPalette::new();
1746 palette.register("Alpha", None, &[]);
1747 palette.register("Beta", None, &[]);
1748 palette.open();
1749 palette.set_query("Alpha");
1750 assert!(palette.selected_action().is_some());
1751
1752 palette.clear_actions();
1753 assert_eq!(palette.action_count(), 0);
1754 assert!(palette.selected_action().is_none());
1755 }
1756
1757 #[test]
1758 fn set_query_refilters() {
1759 let mut palette = CommandPalette::new();
1760 palette.register("Alpha", None, &[]);
1761 palette.register("Beta", None, &[]);
1762 palette.open();
1763 palette.set_query("Alpha");
1764 assert_eq!(palette.query(), "Alpha");
1765 assert_eq!(
1766 palette.selected_action().map(|a| a.title.as_str()),
1767 Some("Alpha")
1768 );
1769 palette.set_query("Beta");
1770 assert_eq!(palette.query(), "Beta");
1771 assert_eq!(
1772 palette.selected_action().map(|a| a.title.as_str()),
1773 Some("Beta")
1774 );
1775 }
1776
1777 #[test]
1778 fn events_ignored_when_hidden() {
1779 let mut palette = CommandPalette::new();
1780 let a = Event::Key(KeyEvent {
1782 code: KeyCode::Char('a'),
1783 modifiers: Modifiers::empty(),
1784 kind: KeyEventKind::Press,
1785 });
1786 assert!(palette.handle_event(&a).is_none());
1787 assert!(!palette.is_visible());
1788 }
1789
1790 #[test]
1795 fn selected_row_has_bold_attribute() {
1796 use ftui_render::grapheme_pool::GraphemePool;
1797
1798 let mut palette = CommandPalette::new();
1799 palette.register("Alpha", None, &[]);
1800 palette.register("Beta", None, &[]);
1801 palette.open();
1802
1803 let area = Rect::from_size(60, 10);
1804 let mut pool = GraphemePool::new();
1805 let mut frame = Frame::new(60, 10, &mut pool);
1806 palette.render(area, &mut frame);
1807
1808 let palette_y = area.y + area.height / 6;
1811 let result_y = palette_y + 2;
1812
1813 let mut found_bold = false;
1815 for x in 0..60u16 {
1816 if let Some(cell) = frame.buffer.get(x, result_y)
1817 && cell.attrs.flags().contains(CellStyleFlags::BOLD)
1818 {
1819 found_bold = true;
1820 break;
1821 }
1822 }
1823 assert!(
1824 found_bold,
1825 "Selected row should have bold attribute for accessibility"
1826 );
1827 }
1828
1829 #[test]
1830 fn selection_marker_visible() {
1831 use ftui_render::grapheme_pool::GraphemePool;
1832
1833 let mut palette = CommandPalette::new();
1834 palette.register("Alpha", None, &[]);
1835 palette.open();
1836
1837 let area = Rect::from_size(60, 10);
1838 let mut pool = GraphemePool::new();
1839 let mut frame = Frame::new(60, 10, &mut pool);
1840 palette.render(area, &mut frame);
1841
1842 let palette_y = area.y + area.height / 6;
1844 let result_y = palette_y + 2;
1845 let mut found_marker = false;
1846 for x in 0..60u16 {
1847 if let Some(cell) = frame.buffer.get(x, result_y)
1848 && cell.content.as_char() == Some('>')
1849 {
1850 found_marker = true;
1851 break;
1852 }
1853 }
1854 assert!(
1855 found_marker,
1856 "Selection marker '>' should be visible (color-independent indicator)"
1857 );
1858 }
1859
1860 #[test]
1861 fn long_title_truncated_with_ellipsis() {
1862 use ftui_render::grapheme_pool::GraphemePool;
1863
1864 let mut palette = CommandPalette::new().with_max_visible(5);
1865 palette.register(
1866 "This Is A Very Long Action Title That Should Be Truncated With Ellipsis",
1867 None,
1868 &[],
1869 );
1870 palette.open();
1871
1872 let area = Rect::from_size(40, 10);
1874 let mut pool = GraphemePool::new();
1875 let mut frame = Frame::new(40, 10, &mut pool);
1876 palette.render(area, &mut frame);
1877
1878 let palette_y = area.y + area.height / 6;
1880 let result_y = palette_y + 2;
1881 let mut found_ellipsis = false;
1882 for x in 0..40u16 {
1883 if let Some(cell) = frame.buffer.get(x, result_y)
1884 && cell.content.as_char() == Some('\u{2026}')
1885 {
1886 found_ellipsis = true;
1887 break;
1888 }
1889 }
1890 assert!(
1891 found_ellipsis,
1892 "Long titles should be truncated with '…' ellipsis"
1893 );
1894 }
1895
1896 #[test]
1897 fn keyboard_only_flow_end_to_end() {
1898 let mut palette = CommandPalette::new();
1899 palette.register("Open File", Some("Open a file from disk"), &["file"]);
1900 palette.register("Save File", Some("Save current file"), &["file"]);
1901 palette.register("Git: Commit", Some("Commit changes"), &["git"]);
1902
1903 let ctrl_p = Event::Key(KeyEvent {
1905 code: KeyCode::Char('p'),
1906 modifiers: Modifiers::CTRL,
1907 kind: KeyEventKind::Press,
1908 });
1909 let _ = palette.handle_event(&ctrl_p);
1910 assert!(palette.is_visible());
1911 assert_eq!(palette.result_count(), 3);
1912
1913 for ch in "git".chars() {
1915 let event = Event::Key(KeyEvent {
1916 code: KeyCode::Char(ch),
1917 modifiers: Modifiers::empty(),
1918 kind: KeyEventKind::Press,
1919 });
1920 let _ = palette.handle_event(&event);
1921 }
1922 assert!(palette.result_count() >= 1);
1923
1924 let down = Event::Key(KeyEvent {
1926 code: KeyCode::Down,
1927 modifiers: Modifiers::empty(),
1928 kind: KeyEventKind::Press,
1929 });
1930 let _ = palette.handle_event(&down);
1931
1932 let up = Event::Key(KeyEvent {
1934 code: KeyCode::Up,
1935 modifiers: Modifiers::empty(),
1936 kind: KeyEventKind::Press,
1937 });
1938 let _ = palette.handle_event(&up);
1939 assert_eq!(palette.selected_index(), 0);
1940
1941 let enter = Event::Key(KeyEvent {
1943 code: KeyCode::Enter,
1944 modifiers: Modifiers::empty(),
1945 kind: KeyEventKind::Press,
1946 });
1947 let result = palette.handle_event(&enter);
1948 assert!(matches!(result, Some(PaletteAction::Execute(_))));
1949 assert!(!palette.is_visible());
1950 }
1951
1952 #[test]
1953 fn no_focus_trap_esc_always_dismisses() {
1954 let mut palette = CommandPalette::new();
1955 palette.register("Alpha", None, &[]);
1956 palette.open();
1957
1958 for ch in "xyz".chars() {
1960 let event = Event::Key(KeyEvent {
1961 code: KeyCode::Char(ch),
1962 modifiers: Modifiers::empty(),
1963 kind: KeyEventKind::Press,
1964 });
1965 let _ = palette.handle_event(&event);
1966 }
1967 assert_eq!(palette.result_count(), 0); let esc = Event::Key(KeyEvent {
1971 code: KeyCode::Escape,
1972 modifiers: Modifiers::empty(),
1973 kind: KeyEventKind::Press,
1974 });
1975 let result = palette.handle_event(&esc);
1976 assert_eq!(result, Some(PaletteAction::Dismiss));
1977 assert!(!palette.is_visible());
1978 }
1979
1980 #[test]
1981 fn unicode_query_renders_correctly() {
1982 use ftui_render::grapheme_pool::GraphemePool;
1983
1984 let mut palette = CommandPalette::new();
1985 palette.register("Café Menu", None, &["food"]);
1986 palette.open();
1987 palette.set_query("café");
1988
1989 assert_eq!(palette.query(), "café");
1990
1991 let area = Rect::from_size(60, 10);
1992 let mut pool = GraphemePool::new();
1993 let mut frame = Frame::new(60, 10, &mut pool);
1994 palette.render(area, &mut frame);
1995
1996 let palette_y = area.y + area.height / 6;
1999 let input_y = palette_y + 1;
2000
2001 let mut found_query_chars = 0;
2003 for x in 0..60u16 {
2004 if let Some(cell) = frame.buffer.get(x, input_y)
2005 && let Some(ch) = cell.content.as_char()
2006 && "café".contains(ch)
2007 {
2008 found_query_chars += 1;
2009 }
2010 }
2011 assert!(
2013 found_query_chars >= 3,
2014 "Unicode query should render (found {} chars)",
2015 found_query_chars
2016 );
2017 }
2018
2019 #[test]
2020 fn wide_char_query_renders_correctly() {
2021 use ftui_render::grapheme_pool::GraphemePool;
2022
2023 let mut palette = CommandPalette::new();
2024 palette.register("日本語メニュー", None, &["japanese"]);
2025 palette.open();
2026 palette.set_query("日本");
2027
2028 assert_eq!(palette.query(), "日本");
2029
2030 let area = Rect::from_size(60, 10);
2031 let mut pool = GraphemePool::new();
2032 let mut frame = Frame::new(60, 10, &mut pool);
2033 palette.render(area, &mut frame);
2034
2035 let palette_y = area.y + area.height / 6;
2037 let input_y = palette_y + 1;
2038
2039 let mut found_grapheme = false;
2041 for x in 0..60u16 {
2042 if let Some(cell) = frame.buffer.get(x, input_y)
2043 && cell.content.is_grapheme()
2044 {
2045 found_grapheme = true;
2046 break;
2047 }
2048 }
2049 assert!(
2050 found_grapheme,
2051 "Wide character query should render as graphemes"
2052 );
2053 }
2054
2055 #[test]
2056 fn wcag_aa_contrast_ratios() {
2057 let style = PaletteStyle::default();
2060 let bg = PackedRgba::rgb(30, 30, 40);
2061
2062 fn relative_luminance(color: PackedRgba) -> f64 {
2064 fn linearize(c: u8) -> f64 {
2065 let v = c as f64 / 255.0;
2066 if v <= 0.04045 {
2067 v / 12.92
2068 } else {
2069 ((v + 0.055) / 1.055).powf(2.4)
2070 }
2071 }
2072 let r = linearize(color.r());
2073 let g = linearize(color.g());
2074 let b = linearize(color.b());
2075 0.2126 * r + 0.7152 * g + 0.0722 * b
2076 }
2077
2078 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
2079 let l1 = relative_luminance(fg);
2080 let l2 = relative_luminance(bg);
2081 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
2082 (lighter + 0.05) / (darker + 0.05)
2083 }
2084
2085 let item_fg = style.item.fg.unwrap();
2087 let item_ratio = contrast_ratio(item_fg, bg);
2088 assert!(
2089 item_ratio >= 4.5,
2090 "Item text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2091 item_ratio
2092 );
2093
2094 let sel_fg = style.item_selected.fg.unwrap();
2096 let sel_bg = style.item_selected.bg.unwrap();
2097 let sel_ratio = contrast_ratio(sel_fg, sel_bg);
2098 assert!(
2099 sel_ratio >= 4.5,
2100 "Selected text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2101 sel_ratio
2102 );
2103
2104 let hl_fg = style.match_highlight.fg.unwrap();
2106 let hl_ratio = contrast_ratio(hl_fg, bg);
2107 assert!(
2108 hl_ratio >= 4.5,
2109 "Highlight text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2110 hl_ratio
2111 );
2112
2113 let desc_fg = style.description.fg.unwrap();
2115 let desc_ratio = contrast_ratio(desc_fg, bg);
2116 assert!(
2117 desc_ratio >= 4.5,
2118 "Description text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
2119 desc_ratio
2120 );
2121 }
2122
2123 #[test]
2124 fn action_item_builder_fields() {
2125 let item = ActionItem::new("my_id", "My Action")
2126 .with_description("A description")
2127 .with_tags(&["tag1", "tag2"])
2128 .with_category("Category");
2129
2130 assert_eq!(item.id, "my_id");
2131 assert_eq!(item.title, "My Action");
2132 assert_eq!(item.description.as_deref(), Some("A description"));
2133 assert_eq!(item.tags, vec!["tag1", "tag2"]);
2134 assert_eq!(item.category.as_deref(), Some("Category"));
2135 }
2136
2137 #[test]
2138 fn action_item_defaults_none() {
2139 let item = ActionItem::new("id", "title");
2140 assert!(item.description.is_none());
2141 assert!(item.tags.is_empty());
2142 assert!(item.category.is_none());
2143 }
2144
2145 #[test]
2146 fn palette_action_equality() {
2147 assert_eq!(PaletteAction::Dismiss, PaletteAction::Dismiss);
2148 assert_eq!(
2149 PaletteAction::Execute("x".into()),
2150 PaletteAction::Execute("x".into())
2151 );
2152 assert_ne!(PaletteAction::Dismiss, PaletteAction::Execute("x".into()));
2153 }
2154
2155 #[test]
2156 fn match_filter_allows_all() {
2157 assert!(MatchFilter::All.allows(MatchType::Exact));
2158 assert!(MatchFilter::All.allows(MatchType::Prefix));
2159 assert!(MatchFilter::All.allows(MatchType::WordStart));
2160 assert!(MatchFilter::All.allows(MatchType::Substring));
2161 assert!(MatchFilter::All.allows(MatchType::Fuzzy));
2162 }
2163
2164 #[test]
2165 fn match_filter_specific_types() {
2166 assert!(MatchFilter::Exact.allows(MatchType::Exact));
2167 assert!(!MatchFilter::Exact.allows(MatchType::Fuzzy));
2168 assert!(MatchFilter::Fuzzy.allows(MatchType::Fuzzy));
2169 assert!(!MatchFilter::Fuzzy.allows(MatchType::Exact));
2170 }
2171
2172 #[test]
2173 fn palette_default_trait() {
2174 let palette = CommandPalette::default();
2175 assert!(!palette.is_visible());
2176 assert_eq!(palette.action_count(), 0);
2177 assert_eq!(palette.query(), "");
2178 }
2179
2180 #[test]
2181 fn with_max_visible_builder() {
2182 let palette = CommandPalette::new().with_max_visible(5);
2183 let mut palette = palette;
2185 for i in 0..10 {
2186 palette.register(format!("Action {i}"), None, &[]);
2187 }
2188 palette.open();
2189 assert_eq!(palette.result_count(), 10);
2190 }
2191
2192 #[test]
2193 fn scorer_stats_accessible() {
2194 let mut palette = CommandPalette::new();
2195 palette.register("Alpha", None, &[]);
2196 palette.open();
2197 palette.set_query("a");
2198 let stats = palette.scorer_stats();
2199 assert!(stats.full_scans + stats.incremental_scans >= 1);
2200 }
2201
2202 #[test]
2203 fn selected_match_returns_match() {
2204 let mut palette = CommandPalette::new();
2205 palette.register("Hello World", None, &[]);
2206 palette.open();
2207 palette.set_query("hello");
2208 let m = palette.selected_match();
2209 assert!(m.is_some());
2210 assert_eq!(m.unwrap().action.title, "Hello World");
2211 }
2212
2213 #[test]
2214 fn results_iterator_returns_matches() {
2215 let mut palette = CommandPalette::new();
2216 palette.register("Alpha", None, &[]);
2217 palette.register("Beta", None, &[]);
2218 palette.open();
2219 let count = palette.results().count();
2220 assert_eq!(count, 2);
2221 }
2222
2223 #[test]
2224 fn set_match_filter_narrows_results() {
2225 let mut palette = CommandPalette::new();
2226 palette.register("Open File", None, &[]);
2227 palette.register("Save File", None, &[]);
2228 palette.open();
2229 palette.set_query("open");
2230 let before = palette.result_count();
2231
2232 palette.set_match_filter(MatchFilter::Exact);
2234 let after = palette.result_count();
2235 assert!(after <= before);
2236 }
2237
2238 #[test]
2239 fn enter_with_no_results_returns_none() {
2240 let mut palette = CommandPalette::new();
2241 palette.register("Alpha", None, &[]);
2242 palette.open();
2243 palette.set_query("zzzznotfound");
2244 assert_eq!(palette.result_count(), 0);
2245
2246 let enter = Event::Key(KeyEvent {
2247 code: KeyCode::Enter,
2248 modifiers: Modifiers::empty(),
2249 kind: KeyEventKind::Press,
2250 });
2251 let result = palette.handle_event(&enter);
2252 assert!(result.is_none());
2253 }
2254
2255 #[cfg(feature = "tracing")]
2256 #[test]
2257 fn telemetry_emits_in_order() {
2258 use std::sync::{Arc, Mutex};
2259 use tracing::Subscriber;
2260 use tracing_subscriber::Layer;
2261 use tracing_subscriber::filter::Targets;
2262 use tracing_subscriber::layer::{Context, SubscriberExt};
2263
2264 #[derive(Default)]
2265 struct EventCapture {
2266 events: Arc<Mutex<Vec<String>>>,
2267 }
2268
2269 impl<S> Layer<S> for EventCapture
2270 where
2271 S: Subscriber,
2272 {
2273 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
2274 use tracing::field::{Field, Visit};
2275
2276 struct EventVisitor {
2277 name: Option<String>,
2278 }
2279
2280 impl Visit for EventVisitor {
2281 fn record_str(&mut self, field: &Field, value: &str) {
2282 if field.name() == "event" {
2283 self.name = Some(value.to_string());
2284 }
2285 }
2286
2287 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2288 if field.name() == "event" {
2289 let raw = format!("{value:?}");
2290 let normalized = raw.trim_matches('\"').to_string();
2291 self.name = Some(normalized);
2292 }
2293 }
2294 }
2295
2296 let mut visitor = EventVisitor { name: None };
2297 event.record(&mut visitor);
2298 if let Some(name) = visitor.name {
2299 self.events
2300 .lock()
2301 .expect("lock telemetry events")
2302 .push(name);
2303 }
2304 }
2305 }
2306
2307 let events = Arc::new(Mutex::new(Vec::new()));
2308 let capture = EventCapture {
2309 events: Arc::clone(&events),
2310 };
2311 let _trace_test_guard = crate::tracing_test_support::acquire();
2312
2313 let subscriber = tracing_subscriber::registry()
2314 .with(capture)
2315 .with(Targets::new().with_target(TELEMETRY_TARGET, tracing::Level::INFO));
2316 let _guard = tracing::subscriber::set_default(subscriber);
2317
2318 let mut palette = CommandPalette::new();
2322 palette.register("Alpha", None, &[]);
2323 tracing::callsite::rebuild_interest_cache();
2324 palette.open();
2325
2326 let a = Event::Key(KeyEvent {
2327 code: KeyCode::Char('a'),
2328 modifiers: Modifiers::empty(),
2329 kind: KeyEventKind::Press,
2330 });
2331 tracing::callsite::rebuild_interest_cache();
2332 let _ = palette.handle_event(&a);
2333
2334 let enter = Event::Key(KeyEvent {
2335 code: KeyCode::Enter,
2336 modifiers: Modifiers::empty(),
2337 kind: KeyEventKind::Press,
2338 });
2339 tracing::callsite::rebuild_interest_cache();
2340 let _ = palette.handle_event(&enter);
2341 tracing::callsite::rebuild_interest_cache();
2342 palette.close();
2343
2344 let events = events.lock().expect("lock telemetry events");
2345 let open_idx = events
2346 .iter()
2347 .position(|e| e == "palette_opened")
2348 .expect("palette_opened missing");
2349 let query_idx = events
2350 .iter()
2351 .position(|e| e == "palette_query_updated")
2352 .expect("palette_query_updated missing");
2353 let exec_idx = events
2354 .iter()
2355 .position(|e| e == "palette_action_executed")
2356 .expect("palette_action_executed missing");
2357 let close_idx = events
2358 .iter()
2359 .position(|e| e == "palette_closed")
2360 .expect("palette_closed missing");
2361
2362 assert!(open_idx < query_idx);
2363 assert!(query_idx < exec_idx);
2364 assert!(exec_idx < close_idx);
2365 }
2366
2367 #[test]
2372 fn compute_word_starts_empty() {
2373 let starts = compute_word_starts("");
2374 assert!(starts.is_empty());
2375 }
2376
2377 #[test]
2378 fn compute_word_starts_single_word() {
2379 let starts = compute_word_starts("hello");
2380 assert_eq!(starts, vec![0]);
2381 }
2382
2383 #[test]
2384 fn compute_word_starts_spaces() {
2385 let starts = compute_word_starts("open file now");
2386 assert_eq!(starts, vec![0, 5, 10]);
2387 }
2388
2389 #[test]
2390 fn compute_word_starts_hyphen_underscore() {
2391 let starts = compute_word_starts("git-commit_push");
2392 assert_eq!(starts, vec![0, 4, 11]);
2394 }
2395
2396 #[test]
2397 fn compute_word_starts_all_separators() {
2398 let starts = compute_word_starts("- _");
2399 assert_eq!(starts, vec![0, 1, 2]);
2402 }
2403
2404 #[test]
2405 fn backspace_on_empty_query_is_noop() {
2406 let mut palette = CommandPalette::new();
2407 palette.register("Alpha", None, &[]);
2408 palette.open();
2409 assert_eq!(palette.query(), "");
2410
2411 let bs = Event::Key(KeyEvent {
2412 code: KeyCode::Backspace,
2413 modifiers: Modifiers::empty(),
2414 kind: KeyEventKind::Press,
2415 });
2416 let _ = palette.handle_event(&bs);
2417 assert_eq!(palette.query(), "");
2418 assert_eq!(palette.result_count(), 1);
2420 }
2421
2422 #[test]
2423 fn ctrl_a_moves_cursor_to_start() {
2424 let mut palette = CommandPalette::new();
2425 palette.register("Alpha", None, &[]);
2426 palette.open();
2427
2428 for ch in "abc".chars() {
2430 let event = Event::Key(KeyEvent {
2431 code: KeyCode::Char(ch),
2432 modifiers: Modifiers::empty(),
2433 kind: KeyEventKind::Press,
2434 });
2435 let _ = palette.handle_event(&event);
2436 }
2437 assert_eq!(palette.query(), "abc");
2438
2439 let ctrl_a = Event::Key(KeyEvent {
2440 code: KeyCode::Char('a'),
2441 modifiers: Modifiers::CTRL,
2442 kind: KeyEventKind::Press,
2443 });
2444 let _ = palette.handle_event(&ctrl_a);
2445 assert_eq!(palette.query(), "abc");
2447 }
2448
2449 #[test]
2450 fn key_release_events_ignored() {
2451 let mut palette = CommandPalette::new();
2452 palette.register("Alpha", None, &[]);
2453 palette.open();
2454
2455 let release = Event::Key(KeyEvent {
2456 code: KeyCode::Char('x'),
2457 modifiers: Modifiers::empty(),
2458 kind: KeyEventKind::Release,
2459 });
2460 let result = palette.handle_event(&release);
2461 assert!(result.is_none());
2462 assert_eq!(palette.query(), "");
2463 }
2464
2465 #[test]
2466 fn resize_event_ignored() {
2467 let mut palette = CommandPalette::new();
2468 palette.open();
2469
2470 let resize = Event::Resize {
2471 width: 80,
2472 height: 24,
2473 };
2474 let result = palette.handle_event(&resize);
2475 assert!(result.is_none());
2476 }
2477
2478 #[test]
2479 fn is_essential_returns_true() {
2480 let palette = CommandPalette::new();
2481 assert!(palette.is_essential());
2482 }
2483
2484 #[test]
2485 fn render_too_small_area_noop() {
2486 use ftui_render::grapheme_pool::GraphemePool;
2487
2488 let mut palette = CommandPalette::new();
2489 palette.register("Alpha", None, &[]);
2490 palette.open();
2491
2492 let area = Rect::new(0, 0, 9, 10);
2494 let mut pool = GraphemePool::new();
2495 let mut frame = Frame::new(20, 20, &mut pool);
2496 palette.render(area, &mut frame);
2497 assert!(frame.cursor_position.is_none());
2499 }
2500
2501 #[test]
2502 fn render_too_short_area_noop() {
2503 use ftui_render::grapheme_pool::GraphemePool;
2504
2505 let mut palette = CommandPalette::new();
2506 palette.register("Alpha", None, &[]);
2507 palette.open();
2508
2509 let area = Rect::new(0, 0, 60, 4);
2511 let mut pool = GraphemePool::new();
2512 let mut frame = Frame::new(60, 10, &mut pool);
2513 palette.render(area, &mut frame);
2514 assert!(frame.cursor_position.is_none());
2515 }
2516
2517 #[test]
2518 fn render_hidden_palette_noop() {
2519 use ftui_render::grapheme_pool::GraphemePool;
2520
2521 let palette = CommandPalette::new();
2522 assert!(!palette.is_visible());
2523
2524 let area = Rect::from_size(60, 10);
2525 let mut pool = GraphemePool::new();
2526 let mut frame = Frame::new(60, 10, &mut pool);
2527 palette.render(area, &mut frame);
2528 assert!(frame.cursor_position.is_none());
2529 }
2530
2531 #[test]
2532 fn render_empty_palette_shows_no_actions_hint() {
2533 use ftui_render::grapheme_pool::GraphemePool;
2534
2535 let mut palette = CommandPalette::new();
2536 palette.open();
2538
2539 let area = Rect::from_size(60, 15);
2540 let mut pool = GraphemePool::new();
2541 let mut frame = Frame::new(60, 15, &mut pool);
2542 palette.render(area, &mut frame);
2543
2544 let palette_y = area.y + area.height / 6;
2546 let result_y = palette_y + 2;
2547 let mut found_n = false;
2548 for x in 0..60u16 {
2549 if let Some(cell) = frame.buffer.get(x, result_y)
2550 && cell.content.as_char() == Some('N')
2551 {
2552 found_n = true;
2553 break;
2554 }
2555 }
2556 assert!(found_n, "Should render 'No actions registered' hint");
2557 }
2558
2559 #[test]
2560 fn render_query_no_results_shows_hint() {
2561 use ftui_render::grapheme_pool::GraphemePool;
2562
2563 let mut palette = CommandPalette::new();
2564 palette.register("Alpha", None, &[]);
2565 palette.open();
2566 palette.set_query("zzzznotfound");
2567 assert_eq!(palette.result_count(), 0);
2568
2569 let area = Rect::from_size(60, 15);
2570 let mut pool = GraphemePool::new();
2571 let mut frame = Frame::new(60, 15, &mut pool);
2572 palette.render(area, &mut frame);
2573
2574 let palette_y = area.y + area.height / 6;
2576 let result_y = palette_y + 2;
2577 let mut found_n = false;
2578 for x in 0..60u16 {
2579 if let Some(cell) = frame.buffer.get(x, result_y)
2580 && cell.content.as_char() == Some('N')
2581 {
2582 found_n = true;
2583 break;
2584 }
2585 }
2586 assert!(found_n, "Should render 'No results' hint");
2587 }
2588
2589 #[test]
2590 fn render_with_category_badge() {
2591 use ftui_render::grapheme_pool::GraphemePool;
2592
2593 let mut palette = CommandPalette::new();
2594 let item = ActionItem::new("git_commit", "Commit Changes").with_category("Git");
2595 palette.register_action(item);
2596 palette.open();
2597
2598 let area = Rect::from_size(80, 15);
2599 let mut pool = GraphemePool::new();
2600 let mut frame = Frame::new(80, 15, &mut pool);
2601 palette.render(area, &mut frame);
2602
2603 let palette_y = area.y + area.height / 6;
2605 let result_y = palette_y + 2;
2606 let mut found_bracket = false;
2607 for x in 0..80u16 {
2608 if let Some(cell) = frame.buffer.get(x, result_y)
2609 && cell.content.as_char() == Some('[')
2610 {
2611 found_bracket = true;
2612 break;
2613 }
2614 }
2615 assert!(found_bracket, "Should render category badge '[Git]'");
2616 }
2617
2618 #[test]
2619 fn render_with_description_text() {
2620 use ftui_render::grapheme_pool::GraphemePool;
2621
2622 let mut palette = CommandPalette::new();
2623 palette.register("Open File", Some("Opens a file from disk"), &[]);
2624 palette.open();
2625
2626 let area = Rect::from_size(80, 15);
2627 let mut pool = GraphemePool::new();
2628 let mut frame = Frame::new(80, 15, &mut pool);
2629 palette.render(area, &mut frame);
2630
2631 let palette_y = area.y + area.height / 6;
2633 let result_y = palette_y + 2;
2634 let mut found_desc_char = false;
2635 for x in 20..80u16 {
2637 if let Some(cell) = frame.buffer.get(x, result_y)
2638 && cell.content.as_char() == Some('O')
2639 {
2640 found_desc_char = true;
2641 break;
2642 }
2643 }
2644 assert!(found_desc_char, "Description text should be rendered");
2645 }
2646
2647 #[test]
2648 fn open_resets_previous_state() {
2649 let mut palette = CommandPalette::new();
2650 palette.register("Alpha", None, &[]);
2651 palette.register("Beta", None, &[]);
2652 palette.open();
2653 palette.set_query("Alpha");
2654
2655 let down = Event::Key(KeyEvent {
2657 code: KeyCode::Down,
2658 modifiers: Modifiers::empty(),
2659 kind: KeyEventKind::Press,
2660 });
2661 let _ = palette.handle_event(&down);
2662
2663 palette.open();
2665 assert_eq!(palette.query(), "");
2666 assert_eq!(palette.selected_index(), 0);
2667 assert_eq!(palette.result_count(), 2);
2668 }
2669
2670 #[test]
2671 fn set_match_filter_same_value_is_noop() {
2672 let mut palette = CommandPalette::new();
2673 palette.register("Alpha", None, &[]);
2674 palette.open();
2675 palette.set_query("alpha");
2676
2677 palette.set_match_filter(MatchFilter::All);
2678 let count1 = palette.result_count();
2679 palette.set_match_filter(MatchFilter::All);
2681 assert_eq!(palette.result_count(), count1);
2682 }
2683
2684 #[test]
2685 fn generation_increments_on_register() {
2686 let mut palette = CommandPalette::new();
2687 palette.register("A", None, &[]);
2688 palette.register("B", None, &[]);
2689 palette.replace_actions(vec![ActionItem::new("c", "C")]);
2692 palette.open();
2693 assert_eq!(palette.action_count(), 1);
2694 }
2695
2696 #[test]
2697 fn enable_evidence_tracking_toggle() {
2698 let mut palette = CommandPalette::new();
2699 palette.register("Alpha", None, &[]);
2700 palette.open();
2701
2702 palette.enable_evidence_tracking(true);
2703 palette.set_query("alpha");
2704 assert!(palette.result_count() >= 1);
2705
2706 palette.enable_evidence_tracking(false);
2707 palette.set_query("alpha");
2708 assert!(palette.result_count() >= 1);
2709 }
2710
2711 #[test]
2712 fn register_chaining() {
2713 let mut palette = CommandPalette::new();
2714 palette
2715 .register("A", None, &[])
2716 .register("B", None, &[])
2717 .register("C", Some("desc"), &["tag"]);
2718 assert_eq!(palette.action_count(), 3);
2719 }
2720
2721 #[test]
2722 fn register_action_chaining() {
2723 let mut palette = CommandPalette::new();
2724 palette
2725 .register_action(ActionItem::new("a", "A"))
2726 .register_action(ActionItem::new("b", "B"));
2727 assert_eq!(palette.action_count(), 2);
2728 }
2729
2730 #[test]
2731 fn page_up_down_navigation() {
2732 let mut palette = CommandPalette::new().with_max_visible(3);
2733 for i in 0..10 {
2734 palette.register(format!("Action {i}"), None, &[]);
2735 }
2736 palette.open();
2737 assert_eq!(palette.selected_index(), 0);
2738
2739 let pgdn = Event::Key(KeyEvent {
2740 code: KeyCode::PageDown,
2741 modifiers: Modifiers::empty(),
2742 kind: KeyEventKind::Press,
2743 });
2744 let _ = palette.handle_event(&pgdn);
2745 assert_eq!(palette.selected_index(), 3); let _ = palette.handle_event(&pgdn);
2748 assert_eq!(palette.selected_index(), 6);
2749
2750 let _ = palette.handle_event(&pgdn);
2751 assert_eq!(palette.selected_index(), 9); let pgup = Event::Key(KeyEvent {
2754 code: KeyCode::PageUp,
2755 modifiers: Modifiers::empty(),
2756 kind: KeyEventKind::Press,
2757 });
2758 let _ = palette.handle_event(&pgup);
2759 assert_eq!(palette.selected_index(), 6); let _ = palette.handle_event(&pgup);
2762 assert_eq!(palette.selected_index(), 3);
2763
2764 let _ = palette.handle_event(&pgup);
2765 assert_eq!(palette.selected_index(), 0);
2766 }
2767
2768 #[test]
2769 fn page_down_empty_results_is_noop() {
2770 let mut palette = CommandPalette::new();
2771 palette.open();
2772 assert_eq!(palette.result_count(), 0);
2773
2774 let pgdn = Event::Key(KeyEvent {
2775 code: KeyCode::PageDown,
2776 modifiers: Modifiers::empty(),
2777 kind: KeyEventKind::Press,
2778 });
2779 let _ = palette.handle_event(&pgdn);
2780 assert_eq!(palette.selected_index(), 0);
2781 }
2782
2783 #[test]
2784 fn end_empty_results_is_noop() {
2785 let mut palette = CommandPalette::new();
2786 palette.open();
2787 assert_eq!(palette.result_count(), 0);
2788
2789 let end = Event::Key(KeyEvent {
2790 code: KeyCode::End,
2791 modifiers: Modifiers::empty(),
2792 kind: KeyEventKind::Press,
2793 });
2794 let _ = palette.handle_event(&end);
2795 assert_eq!(palette.selected_index(), 0);
2796 }
2797
2798 #[test]
2799 fn down_empty_results_is_noop() {
2800 let mut palette = CommandPalette::new();
2801 palette.open();
2802 assert_eq!(palette.result_count(), 0);
2803
2804 let down = Event::Key(KeyEvent {
2805 code: KeyCode::Down,
2806 modifiers: Modifiers::empty(),
2807 kind: KeyEventKind::Press,
2808 });
2809 let _ = palette.handle_event(&down);
2810 assert_eq!(palette.selected_index(), 0);
2811 }
2812
2813 #[test]
2814 fn selected_action_none_when_empty() {
2815 let mut palette = CommandPalette::new();
2816 palette.open();
2817 assert!(palette.selected_action().is_none());
2818 assert!(palette.selected_match().is_none());
2819 }
2820
2821 #[test]
2822 fn results_iterator_empty() {
2823 let mut palette = CommandPalette::new();
2824 palette.open();
2825 assert_eq!(palette.results().count(), 0);
2826 }
2827
2828 #[test]
2829 fn scroll_adjust_keeps_selection_visible() {
2830 let mut palette = CommandPalette::new().with_max_visible(3);
2831 for i in 0..10 {
2832 palette.register(format!("Action {i}"), None, &[]);
2833 }
2834 palette.open();
2835
2836 let end = Event::Key(KeyEvent {
2837 code: KeyCode::End,
2838 modifiers: Modifiers::empty(),
2839 kind: KeyEventKind::Press,
2840 });
2841 let _ = palette.handle_event(&end);
2842 assert_eq!(palette.selected_index(), 9);
2843 let home = Event::Key(KeyEvent {
2847 code: KeyCode::Home,
2848 modifiers: Modifiers::empty(),
2849 kind: KeyEventKind::Press,
2850 });
2851 let _ = palette.handle_event(&home);
2852 assert_eq!(palette.selected_index(), 0);
2853 }
2854
2855 #[test]
2856 fn update_filtered_clamps_scroll_offset_after_results_shrink() {
2857 let mut palette = CommandPalette::new().with_max_visible(3);
2858 for i in 0..10 {
2859 palette.register(format!("Action {i}"), None, &[]);
2860 }
2861 palette.open();
2862
2863 let end = Event::Key(KeyEvent {
2864 code: KeyCode::End,
2865 modifiers: Modifiers::empty(),
2866 kind: KeyEventKind::Press,
2867 });
2868 let _ = palette.handle_event(&end);
2869 assert!(palette.scroll_offset > 0);
2870
2871 palette.actions.truncate(1);
2872 palette.rebuild_title_cache();
2873 palette.generation = palette.generation.wrapping_add(1);
2874 palette.update_filtered(false);
2875
2876 assert_eq!(palette.result_count(), 1);
2877 assert_eq!(palette.selected, 0);
2878 assert_eq!(palette.scroll_offset, 0);
2879 assert_eq!(
2880 palette
2881 .selected_action()
2882 .map(|action| action.title.as_str()),
2883 Some("Action 0")
2884 );
2885 }
2886
2887 #[test]
2888 fn action_item_clone() {
2889 let item = ActionItem::new("id", "Title")
2890 .with_description("Desc")
2891 .with_tags(&["a", "b"])
2892 .with_category("Cat");
2893 let cloned = item.clone();
2894 assert_eq!(cloned.id, "id");
2895 assert_eq!(cloned.title, "Title");
2896 assert_eq!(cloned.description.as_deref(), Some("Desc"));
2897 assert_eq!(cloned.tags, vec!["a", "b"]);
2898 assert_eq!(cloned.category.as_deref(), Some("Cat"));
2899 }
2900
2901 #[test]
2902 fn action_item_debug() {
2903 let item = ActionItem::new("id", "Title");
2904 let debug = format!("{:?}", item);
2905 assert!(debug.contains("ActionItem"));
2906 assert!(debug.contains("Title"));
2907 }
2908
2909 #[test]
2910 fn palette_action_clone_and_debug() {
2911 let exec = PaletteAction::Execute("test".into());
2912 let cloned = exec.clone();
2913 assert_eq!(exec, cloned);
2914
2915 let dismiss = PaletteAction::Dismiss;
2916 let debug = format!("{:?}", dismiss);
2917 assert!(debug.contains("Dismiss"));
2918 }
2919
2920 #[test]
2921 fn match_filter_traits() {
2922 let f = MatchFilter::Fuzzy;
2924 let debug = format!("{:?}", f);
2925 assert!(debug.contains("Fuzzy"));
2926
2927 let f2 = f;
2929 assert_eq!(f, f2);
2930
2931 assert_eq!(MatchFilter::All, MatchFilter::All);
2933 assert_ne!(MatchFilter::Exact, MatchFilter::Prefix);
2934 }
2935
2936 #[test]
2937 fn match_filter_specific_allows() {
2938 assert!(MatchFilter::Prefix.allows(MatchType::Prefix));
2939 assert!(!MatchFilter::Prefix.allows(MatchType::Exact));
2940 assert!(!MatchFilter::Prefix.allows(MatchType::Substring));
2941
2942 assert!(MatchFilter::WordStart.allows(MatchType::WordStart));
2943 assert!(!MatchFilter::WordStart.allows(MatchType::Fuzzy));
2944
2945 assert!(MatchFilter::Substring.allows(MatchType::Substring));
2946 assert!(!MatchFilter::Substring.allows(MatchType::WordStart));
2947 }
2948
2949 #[test]
2950 fn palette_style_default_has_all_colors() {
2951 let style = PaletteStyle::default();
2952 assert!(style.border.fg.is_some());
2953 assert!(style.input.fg.is_some());
2954 assert!(style.item.fg.is_some());
2955 assert!(style.item_selected.fg.is_some());
2956 assert!(style.item_selected.bg.is_some());
2957 assert!(style.match_highlight.fg.is_some());
2958 assert!(style.description.fg.is_some());
2959 assert!(style.category.fg.is_some());
2960 assert!(style.hint.fg.is_some());
2961 }
2962
2963 #[test]
2964 fn palette_style_debug_and_clone() {
2965 let style = PaletteStyle::default();
2966 let debug = format!("{:?}", style);
2967 assert!(debug.contains("PaletteStyle"));
2968
2969 let cloned = style.clone();
2970 assert_eq!(cloned.border.fg, style.border.fg);
2972 }
2973
2974 #[test]
2975 fn with_style_builder() {
2976 let style = PaletteStyle::default();
2977 let palette = CommandPalette::new().with_style(style);
2978 assert!(!palette.is_visible());
2980 }
2981
2982 #[test]
2983 fn command_palette_debug() {
2984 let mut palette = CommandPalette::new();
2985 palette.register("Alpha", None, &[]);
2986 let debug = format!("{:?}", palette);
2987 assert!(debug.contains("CommandPalette"));
2988 }
2989
2990 #[test]
2991 fn unrecognized_key_returns_none() {
2992 let mut palette = CommandPalette::new();
2993 palette.open();
2994
2995 let tab = Event::Key(KeyEvent {
2996 code: KeyCode::Tab,
2997 modifiers: Modifiers::empty(),
2998 kind: KeyEventKind::Press,
2999 });
3000 let result = palette.handle_event(&tab);
3001 assert!(result.is_none());
3002 }
3003
3004 #[test]
3005 fn ctrl_p_when_visible_does_not_reopen() {
3006 let mut palette = CommandPalette::new();
3007 palette.register("Alpha", None, &[]);
3008 palette.open();
3009 palette.set_query("test");
3010
3011 let ctrl_p = Event::Key(KeyEvent {
3013 code: KeyCode::Char('p'),
3014 modifiers: Modifiers::CTRL,
3015 kind: KeyEventKind::Press,
3016 });
3017 let _ = palette.handle_event(&ctrl_p);
3018 assert!(palette.is_visible());
3020 }
3021
3022 #[test]
3023 fn close_clears_query_and_results() {
3024 let mut palette = CommandPalette::new();
3025 palette.register("Alpha", None, &[]);
3026 palette.open();
3027 palette.set_query("alpha");
3028 assert!(!palette.query().is_empty());
3029 assert!(palette.result_count() > 0);
3030
3031 palette.close();
3032 assert!(!palette.is_visible());
3033 assert_eq!(palette.query(), "");
3034 assert_eq!(palette.result_count(), 0);
3035 }
3036
3037 #[test]
3038 fn render_cursor_position_set() {
3039 use ftui_render::grapheme_pool::GraphemePool;
3040
3041 let mut palette = CommandPalette::new();
3042 palette.register("Alpha", None, &[]);
3043 palette.open();
3044
3045 let area = Rect::from_size(60, 15);
3046 let mut pool = GraphemePool::new();
3047 let mut frame = Frame::new(60, 15, &mut pool);
3048 palette.render(area, &mut frame);
3049
3050 assert!(frame.cursor_position.is_some());
3051 assert!(frame.cursor_visible);
3052 }
3053
3054 #[test]
3055 fn render_uses_rounded_corners_for_overlay_border() {
3056 use ftui_render::grapheme_pool::GraphemePool;
3057
3058 let mut palette = CommandPalette::new();
3059 palette.register("Alpha", None, &[]);
3060 palette.open();
3061
3062 let area = Rect::from_size(60, 15);
3063 let mut pool = GraphemePool::new();
3064 let mut frame = Frame::new(60, 15, &mut pool);
3065 palette.render(area, &mut frame);
3066
3067 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
3068 let result_rows = palette.result_count().min(palette.max_visible);
3069 let palette_height = (result_rows as u16 + 3)
3070 .max(5)
3071 .min(area.height.saturating_sub(2));
3072 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
3073 let palette_y = area.y + area.height / 6;
3074 let right = palette_x + palette_width - 1;
3075 let bottom = palette_y + palette_height - 1;
3076
3077 assert_eq!(
3078 frame
3079 .buffer
3080 .get(palette_x, palette_y)
3081 .and_then(|c| c.content.as_char()),
3082 Some('╭')
3083 );
3084 assert_eq!(
3085 frame
3086 .buffer
3087 .get(right, palette_y)
3088 .and_then(|c| c.content.as_char()),
3089 Some('╮')
3090 );
3091 assert_eq!(
3092 frame
3093 .buffer
3094 .get(palette_x, bottom)
3095 .and_then(|c| c.content.as_char()),
3096 Some('╰')
3097 );
3098 assert_eq!(
3099 frame
3100 .buffer
3101 .get(right, bottom)
3102 .and_then(|c| c.content.as_char()),
3103 Some('╯')
3104 );
3105 }
3106
3107 #[test]
3108 fn render_many_items_with_scroll() {
3109 use ftui_render::grapheme_pool::GraphemePool;
3110
3111 let mut palette = CommandPalette::new().with_max_visible(3);
3112 for i in 0..20 {
3113 palette.register(format!("Action {i}"), None, &[]);
3114 }
3115 palette.open();
3116
3117 let end = Event::Key(KeyEvent {
3119 code: KeyCode::End,
3120 modifiers: Modifiers::empty(),
3121 kind: KeyEventKind::Press,
3122 });
3123 let _ = palette.handle_event(&end);
3124
3125 let area = Rect::from_size(60, 15);
3126 let mut pool = GraphemePool::new();
3127 let mut frame = Frame::new(60, 15, &mut pool);
3128 palette.render(area, &mut frame);
3130 assert!(frame.cursor_position.is_some());
3131 }
3132}
3133mod property_tests;