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 #[cfg(feature = "tracing")]
373 opened_at: Option<Instant>,
374}
375
376impl Default for CommandPalette {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382impl CommandPalette {
383 pub fn new() -> Self {
385 Self {
386 actions: Vec::new(),
387 titles_cache: Vec::new(),
388 titles_lower: Vec::new(),
389 titles_word_starts: Vec::new(),
390 query: String::new(),
391 cursor: 0,
392 selected: 0,
393 scroll_offset: 0,
394 visible: false,
395 style: PaletteStyle::default(),
396 scorer: IncrementalScorer::new(),
397 filtered: Vec::new(),
398 match_filter: MatchFilter::All,
399 generation: 0,
400 max_visible: 10,
401 #[cfg(feature = "tracing")]
402 opened_at: None,
403 }
404 }
405
406 #[must_use]
408 pub fn with_style(mut self, style: PaletteStyle) -> Self {
409 self.style = style;
410 self
411 }
412
413 #[must_use]
415 pub fn with_max_visible(mut self, n: usize) -> Self {
416 self.max_visible = n;
417 self
418 }
419
420 pub fn enable_evidence_tracking(&mut self, enabled: bool) {
422 self.scorer = if enabled {
423 IncrementalScorer::with_scorer(BayesianScorer::new())
424 } else {
425 IncrementalScorer::new()
426 };
427 self.update_filtered(false);
428 }
429
430 fn push_title_cache_into(
433 titles_cache: &mut Vec<String>,
434 titles_lower: &mut Vec<String>,
435 titles_word_starts: &mut Vec<Vec<usize>>,
436 title: &str,
437 ) {
438 titles_cache.push(title.to_string());
439 let lower = title.to_lowercase();
440 titles_word_starts.push(compute_word_starts(&lower));
441 titles_lower.push(lower);
442 }
443
444 fn push_title_cache(&mut self, title: &str) {
445 Self::push_title_cache_into(
446 &mut self.titles_cache,
447 &mut self.titles_lower,
448 &mut self.titles_word_starts,
449 title,
450 );
451 }
452
453 fn rebuild_title_cache(&mut self) {
454 self.titles_cache.clear();
455 self.titles_lower.clear();
456 self.titles_word_starts.clear();
457
458 self.titles_cache.reserve(self.actions.len());
459 self.titles_lower.reserve(self.actions.len());
460 self.titles_word_starts.reserve(self.actions.len());
461
462 let titles_cache = &mut self.titles_cache;
463 let titles_lower = &mut self.titles_lower;
464 let titles_word_starts = &mut self.titles_word_starts;
465 for action in &self.actions {
466 Self::push_title_cache_into(
467 titles_cache,
468 titles_lower,
469 titles_word_starts,
470 &action.title,
471 );
472 }
473 }
474
475 pub fn register(
477 &mut self,
478 title: impl Into<String>,
479 description: Option<&str>,
480 tags: &[&str],
481 ) -> &mut Self {
482 let title = title.into();
483 let id = title.to_lowercase().replace(' ', "_");
484 let mut item = ActionItem::new(id, title);
485 if let Some(desc) = description {
486 item.description = Some(desc.to_string());
487 }
488 item.tags = tags.iter().map(|s| (*s).to_string()).collect();
489 self.push_title_cache(&item.title);
490 self.actions.push(item);
491 self.generation = self.generation.wrapping_add(1);
492 self
493 }
494
495 pub fn register_action(&mut self, action: ActionItem) -> &mut Self {
497 self.push_title_cache(&action.title);
498 self.actions.push(action);
499 self.generation = self.generation.wrapping_add(1);
500 self
501 }
502
503 pub fn replace_actions(&mut self, actions: Vec<ActionItem>) {
507 self.actions = actions;
508 self.rebuild_title_cache();
509 self.generation = self.generation.wrapping_add(1);
510 self.scorer.invalidate();
511 self.selected = 0;
512 self.scroll_offset = 0;
513 self.update_filtered(false);
514 }
515
516 pub fn clear_actions(&mut self) {
518 self.replace_actions(Vec::new());
519 }
520
521 pub fn action_count(&self) -> usize {
523 self.actions.len()
524 }
525
526 pub fn open(&mut self) {
530 self.visible = true;
531 self.query.clear();
532 self.cursor = 0;
533 self.selected = 0;
534 self.scroll_offset = 0;
535 self.scorer.invalidate();
536 #[cfg(feature = "tracing")]
537 {
538 self.opened_at = Some(Instant::now());
539 }
540 self.update_filtered(false);
541 #[cfg(feature = "tracing")]
542 #[cfg(test)]
543 tracing::callsite::rebuild_interest_cache();
544 #[cfg(feature = "tracing")]
545 emit_palette_opened(self.actions.len(), self.filtered.len());
546 }
547
548 pub fn close(&mut self) {
550 self.close_with_reason(PaletteCloseReason::Programmatic);
551 }
552
553 pub fn toggle(&mut self) {
555 if self.visible {
556 self.close_with_reason(PaletteCloseReason::Toggle);
557 } else {
558 self.open();
559 }
560 }
561
562 #[inline]
564 pub fn is_visible(&self) -> bool {
565 self.visible
566 }
567
568 pub fn query(&self) -> &str {
572 &self.query
573 }
574
575 pub fn set_query(&mut self, query: impl Into<String>) {
577 self.query = query.into();
578 self.cursor = self.query.len();
579 self.selected = 0;
580 self.scroll_offset = 0;
581 self.scorer.invalidate();
582 self.update_filtered(false);
583 }
584
585 pub fn result_count(&self) -> usize {
587 self.filtered.len()
588 }
589
590 #[inline]
592 pub fn selected_index(&self) -> usize {
593 self.selected
594 }
595
596 #[must_use = "use the selected action (if any)"]
598 pub fn selected_action(&self) -> Option<&ActionItem> {
599 self.filtered
600 .get(self.selected)
601 .map(|si| &self.actions[si.action_index])
602 }
603
604 #[must_use = "use the selected match (if any)"]
606 pub fn selected_match(&self) -> Option<PaletteMatch<'_>> {
607 self.filtered.get(self.selected).map(|si| PaletteMatch {
608 action: &self.actions[si.action_index],
609 result: &si.result,
610 })
611 }
612
613 pub fn results(&self) -> impl Iterator<Item = PaletteMatch<'_>> {
615 self.filtered.iter().map(|si| PaletteMatch {
616 action: &self.actions[si.action_index],
617 result: &si.result,
618 })
619 }
620
621 pub fn set_match_filter(&mut self, filter: MatchFilter) {
623 if self.match_filter == filter {
624 return;
625 }
626 self.match_filter = filter;
627 self.selected = 0;
628 self.scroll_offset = 0;
629 self.update_filtered(false);
630 }
631
632 pub fn scorer_stats(&self) -> &IncrementalStats {
634 self.scorer.stats()
635 }
636
637 #[must_use = "use the returned action (if any) to execute or dismiss"]
645 pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
646 if !self.visible {
647 if let Event::Key(KeyEvent {
649 code: KeyCode::Char('p'),
650 modifiers,
651 kind: KeyEventKind::Press,
652 }) = event
653 && modifiers.contains(Modifiers::CTRL)
654 {
655 self.open();
656 }
657 return None;
658 }
659
660 match event {
661 Event::Key(KeyEvent {
662 code,
663 modifiers,
664 kind: KeyEventKind::Press,
665 }) => self.handle_key(*code, *modifiers),
666 _ => None,
667 }
668 }
669
670 fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Option<PaletteAction> {
672 match code {
673 KeyCode::Escape => {
674 self.close_with_reason(PaletteCloseReason::Dismiss);
675 return Some(PaletteAction::Dismiss);
676 }
677
678 KeyCode::Enter => {
679 if let Some(si) = self.filtered.get(self.selected) {
680 let id = self.actions[si.action_index].id.clone();
681 #[cfg(feature = "tracing")]
682 {
683 let latency_ms = self.opened_at.map(|start| start.elapsed().as_millis());
684 #[cfg(test)]
685 tracing::callsite::rebuild_interest_cache();
686 emit_palette_action_executed(&id, latency_ms);
687 }
688 self.close_with_reason(PaletteCloseReason::Execute);
689 return Some(PaletteAction::Execute(id));
690 }
691 }
692
693 KeyCode::Up => {
694 if self.selected > 0 {
695 self.selected -= 1;
696 self.adjust_scroll();
697 }
698 }
699
700 KeyCode::Down => {
701 if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 {
702 self.selected += 1;
703 self.adjust_scroll();
704 }
705 }
706
707 KeyCode::PageUp => {
708 self.selected = self.selected.saturating_sub(self.max_visible);
709 self.adjust_scroll();
710 }
711
712 KeyCode::PageDown => {
713 if !self.filtered.is_empty() {
714 self.selected = (self.selected + self.max_visible).min(self.filtered.len() - 1);
715 self.adjust_scroll();
716 }
717 }
718
719 KeyCode::Home => {
720 self.selected = 0;
721 self.scroll_offset = 0;
722 }
723
724 KeyCode::End => {
725 if !self.filtered.is_empty() {
726 self.selected = self.filtered.len() - 1;
727 self.adjust_scroll();
728 }
729 }
730
731 KeyCode::Backspace => {
732 if !self.query.is_empty() {
733 self.query.pop();
735 self.cursor = self.query.len();
736 self.selected = 0;
737 self.scroll_offset = 0;
738 self.update_filtered(true);
739 }
740 }
741
742 KeyCode::Char(c) => {
743 if modifiers.contains(Modifiers::CTRL) {
744 if c == 'a' {
746 self.cursor = 0;
747 }
748 if c == 'u' {
750 self.query.clear();
751 self.cursor = 0;
752 self.selected = 0;
753 self.scroll_offset = 0;
754 self.update_filtered(true);
755 }
756 } else {
757 self.query.push(c);
758 self.cursor = self.query.len();
759 self.selected = 0;
760 self.scroll_offset = 0;
761 self.update_filtered(true);
762 }
763 }
764
765 _ => {}
766 }
767
768 None
769 }
770
771 fn update_filtered(&mut self, _emit_telemetry: bool) {
773 #[cfg(feature = "tracing")]
774 let start = _emit_telemetry.then(Instant::now);
778
779 if self.titles_cache.len() != self.actions.len()
780 || self.titles_lower.len() != self.actions.len()
781 || self.titles_word_starts.len() != self.actions.len()
782 {
783 self.rebuild_title_cache();
784 }
785
786 let results = self.scorer.score_corpus_with_lowered_and_words(
787 &self.query,
788 &self.titles_cache,
789 &self.titles_lower,
790 &self.titles_word_starts,
791 Some(self.generation),
792 );
793
794 self.filtered = results
795 .into_iter()
796 .filter(|(_, result)| self.match_filter.allows(result.match_type))
797 .map(|(idx, result)| ScoredItem {
798 action_index: idx,
799 result,
800 })
801 .collect();
802
803 if !self.filtered.is_empty() {
805 self.selected = self.selected.min(self.filtered.len() - 1);
806 } else {
807 self.selected = 0;
808 }
809
810 #[cfg(feature = "tracing")]
811 if let Some(start) = start {
812 #[cfg(test)]
813 tracing::callsite::rebuild_interest_cache();
814 emit_palette_query_updated(
815 &self.query,
816 self.filtered.len(),
817 start.elapsed().as_millis(),
818 );
819 }
820 }
821
822 fn close_with_reason(&mut self, _reason: PaletteCloseReason) {
823 self.visible = false;
824 self.query.clear();
825 self.cursor = 0;
826 self.filtered.clear();
827 #[cfg(feature = "tracing")]
828 {
829 self.opened_at = None;
830 #[cfg(test)]
831 tracing::callsite::rebuild_interest_cache();
832 emit_palette_closed(_reason);
833 }
834 }
835
836 fn adjust_scroll(&mut self) {
838 if self.selected < self.scroll_offset {
839 self.scroll_offset = self.selected;
840 } else if self.selected >= self.scroll_offset + self.max_visible {
841 self.scroll_offset = self.selected + 1 - self.max_visible;
842 }
843 }
844}
845
846impl Widget for CommandPalette {
851 fn render(&self, area: Rect, frame: &mut Frame) {
852 if !self.visible || area.width < 10 || area.height < 5 {
853 return;
854 }
855
856 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
858 let result_rows = self.filtered.len().min(self.max_visible);
859 let palette_height = (result_rows as u16 + 3)
861 .max(5)
862 .min(area.height.saturating_sub(2));
863 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
864 let palette_y = area.y + area.height / 6; let palette_area = Rect::new(palette_x, palette_y, palette_width, palette_height);
867
868 self.clear_area(palette_area, frame);
870
871 self.draw_border(palette_area, frame);
873
874 let input_area = Rect::new(
876 palette_area.x + 2,
877 palette_area.y + 1,
878 palette_area.width.saturating_sub(4),
879 1,
880 );
881 self.draw_query_input(input_area, frame);
882
883 let results_y = palette_area.y + 2;
885 let results_height = palette_area.height.saturating_sub(3);
886 let results_area = Rect::new(
887 palette_area.x + 1,
888 results_y,
889 palette_area.width.saturating_sub(2),
890 results_height,
891 );
892 self.draw_results(results_area, frame);
893
894 let cursor_visual_pos = display_width(&self.query[..self.cursor.min(self.query.len())]);
898 let cursor_x = input_area.x + cursor_visual_pos.min(input_area.width as usize) as u16;
899 frame.cursor_position = Some((cursor_x, input_area.y));
900 frame.cursor_visible = true;
901 }
902
903 fn is_essential(&self) -> bool {
904 true
905 }
906}
907
908impl CommandPalette {
909 fn clear_area(&self, area: Rect, frame: &mut Frame) {
911 let bg = PackedRgba::rgb(30, 30, 40);
912 for y in area.y..area.bottom() {
913 for x in area.x..area.right() {
914 if let Some(cell) = frame.buffer.get_mut(x, y) {
915 *cell = Cell::from_char(' ');
916 cell.bg = bg;
917 }
918 }
919 }
920 }
921
922 fn draw_border(&self, area: Rect, frame: &mut Frame) {
924 let border_fg = self
925 .style
926 .border
927 .fg
928 .unwrap_or(PackedRgba::rgb(100, 100, 120));
929 let bg = PackedRgba::rgb(30, 30, 40);
930
931 if let Some(cell) = frame.buffer.get_mut(area.x, area.y) {
933 cell.content = CellContent::from_char('┌');
934 cell.fg = border_fg;
935 cell.bg = bg;
936 }
937 for x in (area.x + 1)..area.right().saturating_sub(1) {
938 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
939 cell.content = CellContent::from_char('─');
940 cell.fg = border_fg;
941 cell.bg = bg;
942 }
943 }
944 if area.width > 1
945 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, area.y)
946 {
947 cell.content = CellContent::from_char('┐');
948 cell.fg = border_fg;
949 cell.bg = bg;
950 }
951
952 let title = " Command Palette ";
954 let title_width = display_width(title).min(area.width as usize);
955 let title_x = area.x + (area.width.saturating_sub(title_width as u16)) / 2;
956 let title_style = Style::new().fg(PackedRgba::rgb(200, 200, 220)).bg(bg);
957 crate::draw_text_span(frame, title_x, area.y, title, title_style, area.right());
958
959 for y in (area.y + 1)..area.bottom().saturating_sub(1) {
961 if let Some(cell) = frame.buffer.get_mut(area.x, y) {
962 cell.content = CellContent::from_char('│');
963 cell.fg = border_fg;
964 cell.bg = bg;
965 }
966 if area.width > 1
967 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, y)
968 {
969 cell.content = CellContent::from_char('│');
970 cell.fg = border_fg;
971 cell.bg = bg;
972 }
973 }
974
975 if area.height > 1 {
977 let by = area.bottom() - 1;
978 if let Some(cell) = frame.buffer.get_mut(area.x, by) {
979 cell.content = CellContent::from_char('└');
980 cell.fg = border_fg;
981 cell.bg = bg;
982 }
983 for x in (area.x + 1)..area.right().saturating_sub(1) {
984 if let Some(cell) = frame.buffer.get_mut(x, by) {
985 cell.content = CellContent::from_char('─');
986 cell.fg = border_fg;
987 cell.bg = bg;
988 }
989 }
990 if area.width > 1
991 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, by)
992 {
993 cell.content = CellContent::from_char('┘');
994 cell.fg = border_fg;
995 cell.bg = bg;
996 }
997 }
998 }
999
1000 fn draw_query_input(&self, area: Rect, frame: &mut Frame) {
1002 let input_fg = self
1003 .style
1004 .input
1005 .fg
1006 .unwrap_or(PackedRgba::rgb(220, 220, 230));
1007 let bg = PackedRgba::rgb(30, 30, 40);
1008 let prompt_fg = PackedRgba::rgb(100, 180, 255);
1009
1010 if let Some(cell) = frame.buffer.get_mut(area.x.saturating_sub(1), area.y) {
1012 cell.content = CellContent::from_char('>');
1013 cell.fg = prompt_fg;
1014 cell.bg = bg;
1015 }
1016
1017 if self.query.is_empty() {
1019 let hint = "Type to search...";
1021 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1022 for (i, ch) in hint.chars().enumerate() {
1023 let x = area.x + i as u16;
1024 if x >= area.right() {
1025 break;
1026 }
1027 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1028 cell.content = CellContent::from_char(ch);
1029 cell.fg = hint_fg;
1030 cell.bg = bg;
1031 }
1032 }
1033 } else {
1034 let mut col = area.x;
1036 for grapheme in graphemes(&self.query) {
1037 let w = grapheme_width(grapheme);
1038 if w == 0 {
1039 continue;
1040 }
1041 if col >= area.right() {
1042 break;
1043 }
1044 if col.saturating_add(w as u16) > area.right() {
1045 break;
1046 }
1047 let content = if w > 1 || grapheme.chars().count() > 1 {
1048 let id = frame.intern_with_width(grapheme, w as u8);
1049 CellContent::from_grapheme(id)
1050 } else if let Some(ch) = grapheme.chars().next() {
1051 CellContent::from_char(ch)
1052 } else {
1053 continue;
1054 };
1055 if let Some(cell) = frame.buffer.get_mut(col, area.y) {
1056 cell.content = content;
1057 cell.fg = input_fg;
1058 cell.bg = bg;
1059 }
1060 col = col.saturating_add(w as u16);
1061 }
1062 }
1063 }
1064
1065 fn draw_results(&self, area: Rect, frame: &mut Frame) {
1067 if self.filtered.is_empty() {
1068 let msg = if self.query.is_empty() {
1070 "No actions registered"
1071 } else {
1072 "No results"
1073 };
1074 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1075 let bg = PackedRgba::rgb(30, 30, 40);
1076 for (i, ch) in msg.chars().enumerate() {
1077 let x = area.x + 1 + i as u16;
1078 if x >= area.right() {
1079 break;
1080 }
1081 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1082 cell.content = CellContent::from_char(ch);
1083 cell.fg = hint_fg;
1084 cell.bg = bg;
1085 }
1086 }
1087 return;
1088 }
1089
1090 let item_fg = self.style.item.fg.unwrap_or(PackedRgba::rgb(180, 180, 190));
1091 let selected_fg = self
1092 .style
1093 .item_selected
1094 .fg
1095 .unwrap_or(PackedRgba::rgb(255, 255, 255));
1096 let selected_bg = self
1097 .style
1098 .item_selected
1099 .bg
1100 .unwrap_or(PackedRgba::rgb(60, 60, 80));
1101 let highlight_fg = self
1102 .style
1103 .match_highlight
1104 .fg
1105 .unwrap_or(PackedRgba::rgb(255, 200, 50));
1106 let desc_fg = self
1107 .style
1108 .description
1109 .fg
1110 .unwrap_or(PackedRgba::rgb(120, 120, 140));
1111 let cat_fg = self
1112 .style
1113 .category
1114 .fg
1115 .unwrap_or(PackedRgba::rgb(100, 180, 255));
1116 let bg = PackedRgba::rgb(30, 30, 40);
1117
1118 let visible_end = (self.scroll_offset + area.height as usize).min(self.filtered.len());
1119
1120 for (row_idx, si) in self.filtered[self.scroll_offset..visible_end]
1121 .iter()
1122 .enumerate()
1123 {
1124 let y = area.y + row_idx as u16;
1125 if y >= area.bottom() {
1126 break;
1127 }
1128
1129 let action = &self.actions[si.action_index];
1130 let is_selected = (self.scroll_offset + row_idx) == self.selected;
1131
1132 let row_fg = if is_selected { selected_fg } else { item_fg };
1133 let row_bg = if is_selected { selected_bg } else { bg };
1134
1135 let row_attrs = if is_selected {
1137 CellAttrs::new(CellStyleFlags::BOLD, 0)
1138 } else {
1139 CellAttrs::default()
1140 };
1141
1142 for x in area.x..area.right() {
1144 if let Some(cell) = frame.buffer.get_mut(x, y) {
1145 cell.content = CellContent::from_char(' ');
1146 cell.fg = row_fg;
1147 cell.bg = row_bg;
1148 cell.attrs = row_attrs;
1149 }
1150 }
1151
1152 let mut col = area.x;
1154 if is_selected && let Some(cell) = frame.buffer.get_mut(col, y) {
1155 cell.content = CellContent::from_char('>');
1156 cell.fg = highlight_fg;
1157 cell.bg = row_bg;
1158 cell.attrs = CellAttrs::new(CellStyleFlags::BOLD, 0);
1159 }
1160 col += 2;
1161
1162 if let Some(ref cat) = action.category {
1164 let badge = format!("[{}] ", cat);
1165 for grapheme in graphemes(&badge) {
1166 let w = grapheme_width(grapheme);
1167 if w == 0 {
1168 continue;
1169 }
1170 if col >= area.right() || col.saturating_add(w as u16) > area.right() {
1171 break;
1172 }
1173 let content = if w > 1 || grapheme.chars().count() > 1 {
1174 let id = frame.intern_with_width(grapheme, w as u8);
1175 CellContent::from_grapheme(id)
1176 } else if let Some(ch) = grapheme.chars().next() {
1177 CellContent::from_char(ch)
1178 } else {
1179 continue;
1180 };
1181 let mut cell = Cell::new(content);
1182 cell.fg = cat_fg;
1183 cell.bg = row_bg;
1184 cell.attrs = row_attrs;
1185 frame.buffer.set_fast(col, y, cell);
1186 col = col.saturating_add(w as u16);
1187 }
1188 }
1189
1190 let title_max_width = area.right().saturating_sub(col) as usize;
1192 let title_width = display_width(action.title.as_str());
1193 let needs_ellipsis = title_width > title_max_width && title_max_width > 3;
1194 let title_display_width = if needs_ellipsis {
1195 title_max_width.saturating_sub(1) } else {
1197 title_max_width
1198 };
1199
1200 let mut title_used_width = 0usize;
1201 let mut char_idx = 0usize;
1202 let mut match_cursor = 0usize;
1203 let match_positions = &si.result.match_positions;
1204 for grapheme in graphemes(action.title.as_str()) {
1205 let g_chars = grapheme.chars().count();
1206 let char_end = char_idx + g_chars;
1207 while match_cursor < match_positions.len()
1208 && match_positions[match_cursor] < char_idx
1209 {
1210 match_cursor += 1;
1211 }
1212 let is_match = match_cursor < match_positions.len()
1213 && match_positions[match_cursor] < char_end;
1214
1215 let w = grapheme_width(grapheme);
1216 if w == 0 {
1217 char_idx = char_end;
1218 continue;
1219 }
1220 if title_used_width + w > title_display_width || col >= area.right() {
1221 break;
1222 }
1223 if col.saturating_add(w as u16) > area.right() {
1224 break;
1225 }
1226
1227 let content = if w > 1 || grapheme.chars().count() > 1 {
1228 let id = frame.intern_with_width(grapheme, w as u8);
1229 CellContent::from_grapheme(id)
1230 } else if let Some(ch) = grapheme.chars().next() {
1231 CellContent::from_char(ch)
1232 } else {
1233 char_idx = char_end;
1234 continue;
1235 };
1236
1237 let mut cell = Cell::new(content);
1238 cell.fg = if is_match { highlight_fg } else { row_fg };
1239 cell.bg = row_bg;
1240 cell.attrs = row_attrs;
1241 frame.buffer.set_fast(col, y, cell);
1242
1243 col = col.saturating_add(w as u16);
1244 title_used_width += w;
1245 char_idx = char_end;
1246 }
1247
1248 if needs_ellipsis && col < area.right() {
1250 if let Some(cell) = frame.buffer.get_mut(col, y) {
1251 cell.content = CellContent::from_char('\u{2026}'); cell.fg = row_fg;
1253 cell.bg = row_bg;
1254 cell.attrs = row_attrs;
1255 }
1256 col += 1;
1257 }
1258
1259 if let Some(ref desc) = action.description {
1261 col += 2; let max_desc_width = area.right().saturating_sub(col) as usize;
1263 if max_desc_width > 5 {
1264 let desc_width = display_width(desc.as_str());
1265 let desc_needs_ellipsis = desc_width > max_desc_width && max_desc_width > 3;
1266 let desc_display_width = if desc_needs_ellipsis {
1267 max_desc_width.saturating_sub(1)
1268 } else {
1269 max_desc_width
1270 };
1271
1272 let mut desc_used_width = 0usize;
1273 for grapheme in graphemes(desc.as_str()) {
1274 let w = grapheme_width(grapheme);
1275 if w == 0 {
1276 continue;
1277 }
1278 if desc_used_width + w > desc_display_width || col >= area.right() {
1279 break;
1280 }
1281 if col.saturating_add(w as u16) > area.right() {
1282 break;
1283 }
1284 let content = if w > 1 || grapheme.chars().count() > 1 {
1285 let id = frame.intern_with_width(grapheme, w as u8);
1286 CellContent::from_grapheme(id)
1287 } else if let Some(ch) = grapheme.chars().next() {
1288 CellContent::from_char(ch)
1289 } else {
1290 continue;
1291 };
1292 let mut cell = Cell::new(content);
1293 cell.fg = desc_fg;
1294 cell.bg = row_bg;
1295 cell.attrs = row_attrs;
1296 frame.buffer.set_fast(col, y, cell);
1297 col = col.saturating_add(w as u16);
1298 desc_used_width += w;
1299 }
1300
1301 if desc_needs_ellipsis
1302 && col < area.right()
1303 && let Some(cell) = frame.buffer.get_mut(col, y)
1304 {
1305 cell.content = CellContent::from_char('\u{2026}');
1306 cell.fg = desc_fg;
1307 cell.bg = row_bg;
1308 cell.attrs = row_attrs;
1309 }
1310 }
1311 }
1312 }
1313 }
1314}
1315
1316#[cfg(test)]
1321mod widget_tests {
1322 use super::*;
1323
1324 #[test]
1325 fn new_palette_is_hidden() {
1326 let palette = CommandPalette::new();
1327 assert!(!palette.is_visible());
1328 assert_eq!(palette.action_count(), 0);
1329 }
1330
1331 #[test]
1332 fn register_actions() {
1333 let mut palette = CommandPalette::new();
1334 palette.register("Open File", Some("Open a file"), &["file"]);
1335 palette.register("Save File", None, &[]);
1336 assert_eq!(palette.action_count(), 2);
1337 }
1338
1339 #[test]
1340 fn open_shows_all_actions() {
1341 let mut palette = CommandPalette::new();
1342 palette.register("Open File", None, &[]);
1343 palette.register("Save File", None, &[]);
1344 palette.register("Close Tab", None, &[]);
1345 palette.open();
1346 assert!(palette.is_visible());
1347 assert_eq!(palette.result_count(), 3);
1348 }
1349
1350 #[test]
1351 fn close_hides_palette() {
1352 let mut palette = CommandPalette::new();
1353 palette.open();
1354 assert!(palette.is_visible());
1355 palette.close();
1356 assert!(!palette.is_visible());
1357 }
1358
1359 #[test]
1360 fn toggle_visibility() {
1361 let mut palette = CommandPalette::new();
1362 palette.toggle();
1363 assert!(palette.is_visible());
1364 palette.toggle();
1365 assert!(!palette.is_visible());
1366 }
1367
1368 #[test]
1369 fn typing_filters_results() {
1370 let mut palette = CommandPalette::new();
1371 palette.register("Open File", None, &[]);
1372 palette.register("Save File", None, &[]);
1373 palette.register("Git: Commit", None, &[]);
1374 palette.open();
1375 assert_eq!(palette.result_count(), 3);
1376
1377 let g = Event::Key(KeyEvent {
1379 code: KeyCode::Char('g'),
1380 modifiers: Modifiers::empty(),
1381 kind: KeyEventKind::Press,
1382 });
1383 let i = Event::Key(KeyEvent {
1384 code: KeyCode::Char('i'),
1385 modifiers: Modifiers::empty(),
1386 kind: KeyEventKind::Press,
1387 });
1388 let t = Event::Key(KeyEvent {
1389 code: KeyCode::Char('t'),
1390 modifiers: Modifiers::empty(),
1391 kind: KeyEventKind::Press,
1392 });
1393
1394 let _ = palette.handle_event(&g);
1395 let _ = palette.handle_event(&i);
1396 let _ = palette.handle_event(&t);
1397
1398 assert_eq!(palette.query(), "git");
1399 assert!(palette.result_count() >= 1);
1401 }
1402
1403 #[test]
1404 fn backspace_removes_character() {
1405 let mut palette = CommandPalette::new();
1406 palette.register("Open File", None, &[]);
1407 palette.open();
1408
1409 let o = Event::Key(KeyEvent {
1410 code: KeyCode::Char('o'),
1411 modifiers: Modifiers::empty(),
1412 kind: KeyEventKind::Press,
1413 });
1414 let bs = Event::Key(KeyEvent {
1415 code: KeyCode::Backspace,
1416 modifiers: Modifiers::empty(),
1417 kind: KeyEventKind::Press,
1418 });
1419
1420 let _ = palette.handle_event(&o);
1421 assert_eq!(palette.query(), "o");
1422 let _ = palette.handle_event(&bs);
1423 assert_eq!(palette.query(), "");
1424 }
1425
1426 #[test]
1427 fn esc_dismisses_palette() {
1428 let mut palette = CommandPalette::new();
1429 palette.open();
1430
1431 let esc = Event::Key(KeyEvent {
1432 code: KeyCode::Escape,
1433 modifiers: Modifiers::empty(),
1434 kind: KeyEventKind::Press,
1435 });
1436
1437 let result = palette.handle_event(&esc);
1438 assert_eq!(result, Some(PaletteAction::Dismiss));
1439 assert!(!palette.is_visible());
1440 }
1441
1442 #[test]
1443 fn enter_executes_selected() {
1444 let mut palette = CommandPalette::new();
1445 palette.register("Open File", None, &[]);
1446 palette.open();
1447
1448 let enter = Event::Key(KeyEvent {
1449 code: KeyCode::Enter,
1450 modifiers: Modifiers::empty(),
1451 kind: KeyEventKind::Press,
1452 });
1453
1454 let result = palette.handle_event(&enter);
1455 assert_eq!(result, Some(PaletteAction::Execute("open_file".into())));
1456 }
1457
1458 #[test]
1459 fn arrow_keys_navigate() {
1460 let mut palette = CommandPalette::new();
1461 palette.register("A", None, &[]);
1462 palette.register("B", None, &[]);
1463 palette.register("C", None, &[]);
1464 palette.open();
1465
1466 assert_eq!(palette.selected_index(), 0);
1467
1468 let down = Event::Key(KeyEvent {
1469 code: KeyCode::Down,
1470 modifiers: Modifiers::empty(),
1471 kind: KeyEventKind::Press,
1472 });
1473 let up = Event::Key(KeyEvent {
1474 code: KeyCode::Up,
1475 modifiers: Modifiers::empty(),
1476 kind: KeyEventKind::Press,
1477 });
1478
1479 let _ = palette.handle_event(&down);
1480 assert_eq!(palette.selected_index(), 1);
1481 let _ = palette.handle_event(&down);
1482 assert_eq!(palette.selected_index(), 2);
1483 let _ = palette.handle_event(&down);
1485 assert_eq!(palette.selected_index(), 2);
1486
1487 let _ = palette.handle_event(&up);
1488 assert_eq!(palette.selected_index(), 1);
1489 let _ = palette.handle_event(&up);
1490 assert_eq!(palette.selected_index(), 0);
1491 let _ = palette.handle_event(&up);
1493 assert_eq!(palette.selected_index(), 0);
1494 }
1495
1496 #[test]
1497 fn home_end_navigation() {
1498 let mut palette = CommandPalette::new();
1499 for i in 0..20 {
1500 palette.register(format!("Action {}", i), None, &[]);
1501 }
1502 palette.open();
1503
1504 let end = Event::Key(KeyEvent {
1505 code: KeyCode::End,
1506 modifiers: Modifiers::empty(),
1507 kind: KeyEventKind::Press,
1508 });
1509 let home = Event::Key(KeyEvent {
1510 code: KeyCode::Home,
1511 modifiers: Modifiers::empty(),
1512 kind: KeyEventKind::Press,
1513 });
1514
1515 let _ = palette.handle_event(&end);
1516 assert_eq!(palette.selected_index(), 19);
1517
1518 let _ = palette.handle_event(&home);
1519 assert_eq!(palette.selected_index(), 0);
1520 }
1521
1522 #[test]
1523 fn ctrl_u_clears_query() {
1524 let mut palette = CommandPalette::new();
1525 palette.register("Open File", None, &[]);
1526 palette.open();
1527
1528 let o = Event::Key(KeyEvent {
1529 code: KeyCode::Char('o'),
1530 modifiers: Modifiers::empty(),
1531 kind: KeyEventKind::Press,
1532 });
1533 let _ = palette.handle_event(&o);
1534 assert_eq!(palette.query(), "o");
1535
1536 let ctrl_u = Event::Key(KeyEvent {
1537 code: KeyCode::Char('u'),
1538 modifiers: Modifiers::CTRL,
1539 kind: KeyEventKind::Press,
1540 });
1541 let _ = palette.handle_event(&ctrl_u);
1542 assert_eq!(palette.query(), "");
1543 }
1544
1545 #[test]
1546 fn ctrl_p_opens_palette() {
1547 let mut palette = CommandPalette::new();
1548 assert!(!palette.is_visible());
1549
1550 let ctrl_p = Event::Key(KeyEvent {
1551 code: KeyCode::Char('p'),
1552 modifiers: Modifiers::CTRL,
1553 kind: KeyEventKind::Press,
1554 });
1555 let _ = palette.handle_event(&ctrl_p);
1556 assert!(palette.is_visible());
1557 }
1558
1559 #[test]
1560 fn selected_action_returns_correct_item() {
1561 let mut palette = CommandPalette::new();
1562 palette.register("Alpha", None, &[]);
1563 palette.register("Beta", None, &[]);
1564 palette.open();
1565
1566 let action = palette.selected_action().unwrap();
1567 assert!(!action.title.is_empty());
1569 }
1570
1571 #[test]
1572 fn register_action_item_directly() {
1573 let mut palette = CommandPalette::new();
1574 let item = ActionItem::new("custom_id", "Custom Action")
1575 .with_description("A custom action")
1576 .with_tags(&["custom", "test"])
1577 .with_category("Testing");
1578
1579 palette.register_action(item);
1580 assert_eq!(palette.action_count(), 1);
1581 }
1582
1583 #[test]
1584 fn replace_actions_refreshes_results() {
1585 let mut palette = CommandPalette::new();
1586 palette.register("Alpha", None, &[]);
1587 palette.register("Beta", None, &[]);
1588 palette.open();
1589 palette.set_query("Beta");
1590 assert_eq!(
1591 palette.selected_action().map(|a| a.title.as_str()),
1592 Some("Beta")
1593 );
1594
1595 let actions = vec![
1596 ActionItem::new("gamma", "Gamma"),
1597 ActionItem::new("delta", "Delta"),
1598 ];
1599 palette.replace_actions(actions);
1600 palette.set_query("Delta");
1601 assert_eq!(
1602 palette.selected_action().map(|a| a.title.as_str()),
1603 Some("Delta")
1604 );
1605 }
1606
1607 #[test]
1608 fn clear_actions_resets_results() {
1609 let mut palette = CommandPalette::new();
1610 palette.register("Alpha", None, &[]);
1611 palette.register("Beta", None, &[]);
1612 palette.open();
1613 palette.set_query("Alpha");
1614 assert!(palette.selected_action().is_some());
1615
1616 palette.clear_actions();
1617 assert_eq!(palette.action_count(), 0);
1618 assert!(palette.selected_action().is_none());
1619 }
1620
1621 #[test]
1622 fn set_query_refilters() {
1623 let mut palette = CommandPalette::new();
1624 palette.register("Alpha", None, &[]);
1625 palette.register("Beta", None, &[]);
1626 palette.open();
1627 palette.set_query("Alpha");
1628 assert_eq!(palette.query(), "Alpha");
1629 assert_eq!(
1630 palette.selected_action().map(|a| a.title.as_str()),
1631 Some("Alpha")
1632 );
1633 palette.set_query("Beta");
1634 assert_eq!(palette.query(), "Beta");
1635 assert_eq!(
1636 palette.selected_action().map(|a| a.title.as_str()),
1637 Some("Beta")
1638 );
1639 }
1640
1641 #[test]
1642 fn events_ignored_when_hidden() {
1643 let mut palette = CommandPalette::new();
1644 let a = Event::Key(KeyEvent {
1646 code: KeyCode::Char('a'),
1647 modifiers: Modifiers::empty(),
1648 kind: KeyEventKind::Press,
1649 });
1650 assert!(palette.handle_event(&a).is_none());
1651 assert!(!palette.is_visible());
1652 }
1653
1654 #[test]
1659 fn selected_row_has_bold_attribute() {
1660 use ftui_render::grapheme_pool::GraphemePool;
1661
1662 let mut palette = CommandPalette::new();
1663 palette.register("Alpha", None, &[]);
1664 palette.register("Beta", None, &[]);
1665 palette.open();
1666
1667 let area = Rect::from_size(60, 10);
1668 let mut pool = GraphemePool::new();
1669 let mut frame = Frame::new(60, 10, &mut pool);
1670 palette.render(area, &mut frame);
1671
1672 let palette_y = area.y + area.height / 6;
1675 let result_y = palette_y + 2;
1676
1677 let mut found_bold = false;
1679 for x in 0..60u16 {
1680 if let Some(cell) = frame.buffer.get(x, result_y)
1681 && cell.attrs.flags().contains(CellStyleFlags::BOLD)
1682 {
1683 found_bold = true;
1684 break;
1685 }
1686 }
1687 assert!(
1688 found_bold,
1689 "Selected row should have bold attribute for accessibility"
1690 );
1691 }
1692
1693 #[test]
1694 fn selection_marker_visible() {
1695 use ftui_render::grapheme_pool::GraphemePool;
1696
1697 let mut palette = CommandPalette::new();
1698 palette.register("Alpha", None, &[]);
1699 palette.open();
1700
1701 let area = Rect::from_size(60, 10);
1702 let mut pool = GraphemePool::new();
1703 let mut frame = Frame::new(60, 10, &mut pool);
1704 palette.render(area, &mut frame);
1705
1706 let palette_y = area.y + area.height / 6;
1708 let result_y = palette_y + 2;
1709 let mut found_marker = false;
1710 for x in 0..60u16 {
1711 if let Some(cell) = frame.buffer.get(x, result_y)
1712 && cell.content.as_char() == Some('>')
1713 {
1714 found_marker = true;
1715 break;
1716 }
1717 }
1718 assert!(
1719 found_marker,
1720 "Selection marker '>' should be visible (color-independent indicator)"
1721 );
1722 }
1723
1724 #[test]
1725 fn long_title_truncated_with_ellipsis() {
1726 use ftui_render::grapheme_pool::GraphemePool;
1727
1728 let mut palette = CommandPalette::new().with_max_visible(5);
1729 palette.register(
1730 "This Is A Very Long Action Title That Should Be Truncated With Ellipsis",
1731 None,
1732 &[],
1733 );
1734 palette.open();
1735
1736 let area = Rect::from_size(40, 10);
1738 let mut pool = GraphemePool::new();
1739 let mut frame = Frame::new(40, 10, &mut pool);
1740 palette.render(area, &mut frame);
1741
1742 let palette_y = area.y + area.height / 6;
1744 let result_y = palette_y + 2;
1745 let mut found_ellipsis = false;
1746 for x in 0..40u16 {
1747 if let Some(cell) = frame.buffer.get(x, result_y)
1748 && cell.content.as_char() == Some('\u{2026}')
1749 {
1750 found_ellipsis = true;
1751 break;
1752 }
1753 }
1754 assert!(
1755 found_ellipsis,
1756 "Long titles should be truncated with '…' ellipsis"
1757 );
1758 }
1759
1760 #[test]
1761 fn keyboard_only_flow_end_to_end() {
1762 let mut palette = CommandPalette::new();
1763 palette.register("Open File", Some("Open a file from disk"), &["file"]);
1764 palette.register("Save File", Some("Save current file"), &["file"]);
1765 palette.register("Git: Commit", Some("Commit changes"), &["git"]);
1766
1767 let ctrl_p = Event::Key(KeyEvent {
1769 code: KeyCode::Char('p'),
1770 modifiers: Modifiers::CTRL,
1771 kind: KeyEventKind::Press,
1772 });
1773 let _ = palette.handle_event(&ctrl_p);
1774 assert!(palette.is_visible());
1775 assert_eq!(palette.result_count(), 3);
1776
1777 for ch in "git".chars() {
1779 let event = Event::Key(KeyEvent {
1780 code: KeyCode::Char(ch),
1781 modifiers: Modifiers::empty(),
1782 kind: KeyEventKind::Press,
1783 });
1784 let _ = palette.handle_event(&event);
1785 }
1786 assert!(palette.result_count() >= 1);
1787
1788 let down = Event::Key(KeyEvent {
1790 code: KeyCode::Down,
1791 modifiers: Modifiers::empty(),
1792 kind: KeyEventKind::Press,
1793 });
1794 let _ = palette.handle_event(&down);
1795
1796 let up = Event::Key(KeyEvent {
1798 code: KeyCode::Up,
1799 modifiers: Modifiers::empty(),
1800 kind: KeyEventKind::Press,
1801 });
1802 let _ = palette.handle_event(&up);
1803 assert_eq!(palette.selected_index(), 0);
1804
1805 let enter = Event::Key(KeyEvent {
1807 code: KeyCode::Enter,
1808 modifiers: Modifiers::empty(),
1809 kind: KeyEventKind::Press,
1810 });
1811 let result = palette.handle_event(&enter);
1812 assert!(matches!(result, Some(PaletteAction::Execute(_))));
1813 assert!(!palette.is_visible());
1814 }
1815
1816 #[test]
1817 fn no_focus_trap_esc_always_dismisses() {
1818 let mut palette = CommandPalette::new();
1819 palette.register("Alpha", None, &[]);
1820 palette.open();
1821
1822 for ch in "xyz".chars() {
1824 let event = Event::Key(KeyEvent {
1825 code: KeyCode::Char(ch),
1826 modifiers: Modifiers::empty(),
1827 kind: KeyEventKind::Press,
1828 });
1829 let _ = palette.handle_event(&event);
1830 }
1831 assert_eq!(palette.result_count(), 0); let esc = Event::Key(KeyEvent {
1835 code: KeyCode::Escape,
1836 modifiers: Modifiers::empty(),
1837 kind: KeyEventKind::Press,
1838 });
1839 let result = palette.handle_event(&esc);
1840 assert_eq!(result, Some(PaletteAction::Dismiss));
1841 assert!(!palette.is_visible());
1842 }
1843
1844 #[test]
1845 fn unicode_query_renders_correctly() {
1846 use ftui_render::grapheme_pool::GraphemePool;
1847
1848 let mut palette = CommandPalette::new();
1849 palette.register("Café Menu", None, &["food"]);
1850 palette.open();
1851 palette.set_query("café");
1852
1853 assert_eq!(palette.query(), "café");
1854
1855 let area = Rect::from_size(60, 10);
1856 let mut pool = GraphemePool::new();
1857 let mut frame = Frame::new(60, 10, &mut pool);
1858 palette.render(area, &mut frame);
1859
1860 let palette_y = area.y + area.height / 6;
1863 let input_y = palette_y + 1;
1864
1865 let mut found_query_chars = 0;
1867 for x in 0..60u16 {
1868 if let Some(cell) = frame.buffer.get(x, input_y)
1869 && let Some(ch) = cell.content.as_char()
1870 && "café".contains(ch)
1871 {
1872 found_query_chars += 1;
1873 }
1874 }
1875 assert!(
1877 found_query_chars >= 3,
1878 "Unicode query should render (found {} chars)",
1879 found_query_chars
1880 );
1881 }
1882
1883 #[test]
1884 fn wide_char_query_renders_correctly() {
1885 use ftui_render::grapheme_pool::GraphemePool;
1886
1887 let mut palette = CommandPalette::new();
1888 palette.register("日本語メニュー", None, &["japanese"]);
1889 palette.open();
1890 palette.set_query("日本");
1891
1892 assert_eq!(palette.query(), "日本");
1893
1894 let area = Rect::from_size(60, 10);
1895 let mut pool = GraphemePool::new();
1896 let mut frame = Frame::new(60, 10, &mut pool);
1897 palette.render(area, &mut frame);
1898
1899 let palette_y = area.y + area.height / 6;
1901 let input_y = palette_y + 1;
1902
1903 let mut found_grapheme = false;
1905 for x in 0..60u16 {
1906 if let Some(cell) = frame.buffer.get(x, input_y)
1907 && cell.content.is_grapheme()
1908 {
1909 found_grapheme = true;
1910 break;
1911 }
1912 }
1913 assert!(
1914 found_grapheme,
1915 "Wide character query should render as graphemes"
1916 );
1917 }
1918
1919 #[test]
1920 fn wcag_aa_contrast_ratios() {
1921 let style = PaletteStyle::default();
1924 let bg = PackedRgba::rgb(30, 30, 40);
1925
1926 fn relative_luminance(color: PackedRgba) -> f64 {
1928 fn linearize(c: u8) -> f64 {
1929 let v = c as f64 / 255.0;
1930 if v <= 0.04045 {
1931 v / 12.92
1932 } else {
1933 ((v + 0.055) / 1.055).powf(2.4)
1934 }
1935 }
1936 let r = linearize(color.r());
1937 let g = linearize(color.g());
1938 let b = linearize(color.b());
1939 0.2126 * r + 0.7152 * g + 0.0722 * b
1940 }
1941
1942 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
1943 let l1 = relative_luminance(fg);
1944 let l2 = relative_luminance(bg);
1945 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
1946 (lighter + 0.05) / (darker + 0.05)
1947 }
1948
1949 let item_fg = style.item.fg.unwrap();
1951 let item_ratio = contrast_ratio(item_fg, bg);
1952 assert!(
1953 item_ratio >= 4.5,
1954 "Item text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1955 item_ratio
1956 );
1957
1958 let sel_fg = style.item_selected.fg.unwrap();
1960 let sel_bg = style.item_selected.bg.unwrap();
1961 let sel_ratio = contrast_ratio(sel_fg, sel_bg);
1962 assert!(
1963 sel_ratio >= 4.5,
1964 "Selected text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1965 sel_ratio
1966 );
1967
1968 let hl_fg = style.match_highlight.fg.unwrap();
1970 let hl_ratio = contrast_ratio(hl_fg, bg);
1971 assert!(
1972 hl_ratio >= 4.5,
1973 "Highlight text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1974 hl_ratio
1975 );
1976
1977 let desc_fg = style.description.fg.unwrap();
1979 let desc_ratio = contrast_ratio(desc_fg, bg);
1980 assert!(
1981 desc_ratio >= 4.5,
1982 "Description text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1983 desc_ratio
1984 );
1985 }
1986
1987 #[test]
1988 fn action_item_builder_fields() {
1989 let item = ActionItem::new("my_id", "My Action")
1990 .with_description("A description")
1991 .with_tags(&["tag1", "tag2"])
1992 .with_category("Category");
1993
1994 assert_eq!(item.id, "my_id");
1995 assert_eq!(item.title, "My Action");
1996 assert_eq!(item.description.as_deref(), Some("A description"));
1997 assert_eq!(item.tags, vec!["tag1", "tag2"]);
1998 assert_eq!(item.category.as_deref(), Some("Category"));
1999 }
2000
2001 #[test]
2002 fn action_item_defaults_none() {
2003 let item = ActionItem::new("id", "title");
2004 assert!(item.description.is_none());
2005 assert!(item.tags.is_empty());
2006 assert!(item.category.is_none());
2007 }
2008
2009 #[test]
2010 fn palette_action_equality() {
2011 assert_eq!(PaletteAction::Dismiss, PaletteAction::Dismiss);
2012 assert_eq!(
2013 PaletteAction::Execute("x".into()),
2014 PaletteAction::Execute("x".into())
2015 );
2016 assert_ne!(PaletteAction::Dismiss, PaletteAction::Execute("x".into()));
2017 }
2018
2019 #[test]
2020 fn match_filter_allows_all() {
2021 assert!(MatchFilter::All.allows(MatchType::Exact));
2022 assert!(MatchFilter::All.allows(MatchType::Prefix));
2023 assert!(MatchFilter::All.allows(MatchType::WordStart));
2024 assert!(MatchFilter::All.allows(MatchType::Substring));
2025 assert!(MatchFilter::All.allows(MatchType::Fuzzy));
2026 }
2027
2028 #[test]
2029 fn match_filter_specific_types() {
2030 assert!(MatchFilter::Exact.allows(MatchType::Exact));
2031 assert!(!MatchFilter::Exact.allows(MatchType::Fuzzy));
2032 assert!(MatchFilter::Fuzzy.allows(MatchType::Fuzzy));
2033 assert!(!MatchFilter::Fuzzy.allows(MatchType::Exact));
2034 }
2035
2036 #[test]
2037 fn palette_default_trait() {
2038 let palette = CommandPalette::default();
2039 assert!(!palette.is_visible());
2040 assert_eq!(palette.action_count(), 0);
2041 assert_eq!(palette.query(), "");
2042 }
2043
2044 #[test]
2045 fn with_max_visible_builder() {
2046 let palette = CommandPalette::new().with_max_visible(5);
2047 let mut palette = palette;
2049 for i in 0..10 {
2050 palette.register(format!("Action {i}"), None, &[]);
2051 }
2052 palette.open();
2053 assert_eq!(palette.result_count(), 10);
2054 }
2055
2056 #[test]
2057 fn scorer_stats_accessible() {
2058 let mut palette = CommandPalette::new();
2059 palette.register("Alpha", None, &[]);
2060 palette.open();
2061 palette.set_query("a");
2062 let stats = palette.scorer_stats();
2063 assert!(stats.full_scans + stats.incremental_scans >= 1);
2064 }
2065
2066 #[test]
2067 fn selected_match_returns_match() {
2068 let mut palette = CommandPalette::new();
2069 palette.register("Hello World", None, &[]);
2070 palette.open();
2071 palette.set_query("hello");
2072 let m = palette.selected_match();
2073 assert!(m.is_some());
2074 assert_eq!(m.unwrap().action.title, "Hello World");
2075 }
2076
2077 #[test]
2078 fn results_iterator_returns_matches() {
2079 let mut palette = CommandPalette::new();
2080 palette.register("Alpha", None, &[]);
2081 palette.register("Beta", None, &[]);
2082 palette.open();
2083 let count = palette.results().count();
2084 assert_eq!(count, 2);
2085 }
2086
2087 #[test]
2088 fn set_match_filter_narrows_results() {
2089 let mut palette = CommandPalette::new();
2090 palette.register("Open File", None, &[]);
2091 palette.register("Save File", None, &[]);
2092 palette.open();
2093 palette.set_query("open");
2094 let before = palette.result_count();
2095
2096 palette.set_match_filter(MatchFilter::Exact);
2098 let after = palette.result_count();
2099 assert!(after <= before);
2100 }
2101
2102 #[test]
2103 fn enter_with_no_results_returns_none() {
2104 let mut palette = CommandPalette::new();
2105 palette.register("Alpha", None, &[]);
2106 palette.open();
2107 palette.set_query("zzzznotfound");
2108 assert_eq!(palette.result_count(), 0);
2109
2110 let enter = Event::Key(KeyEvent {
2111 code: KeyCode::Enter,
2112 modifiers: Modifiers::empty(),
2113 kind: KeyEventKind::Press,
2114 });
2115 let result = palette.handle_event(&enter);
2116 assert!(result.is_none());
2117 }
2118
2119 #[cfg(feature = "tracing")]
2120 #[test]
2121 fn telemetry_emits_in_order() {
2122 use std::sync::{Arc, Mutex};
2123 use tracing::Subscriber;
2124 use tracing_subscriber::Layer;
2125 use tracing_subscriber::filter::Targets;
2126 use tracing_subscriber::layer::{Context, SubscriberExt};
2127
2128 #[derive(Default)]
2129 struct EventCapture {
2130 events: Arc<Mutex<Vec<String>>>,
2131 }
2132
2133 impl<S> Layer<S> for EventCapture
2134 where
2135 S: Subscriber,
2136 {
2137 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
2138 use tracing::field::{Field, Visit};
2139
2140 struct EventVisitor {
2141 name: Option<String>,
2142 }
2143
2144 impl Visit for EventVisitor {
2145 fn record_str(&mut self, field: &Field, value: &str) {
2146 if field.name() == "event" {
2147 self.name = Some(value.to_string());
2148 }
2149 }
2150
2151 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2152 if field.name() == "event" {
2153 let raw = format!("{value:?}");
2154 let normalized = raw.trim_matches('\"').to_string();
2155 self.name = Some(normalized);
2156 }
2157 }
2158 }
2159
2160 let mut visitor = EventVisitor { name: None };
2161 event.record(&mut visitor);
2162 if let Some(name) = visitor.name {
2163 self.events
2164 .lock()
2165 .expect("lock telemetry events")
2166 .push(name);
2167 }
2168 }
2169 }
2170
2171 let events = Arc::new(Mutex::new(Vec::new()));
2172 let capture = EventCapture {
2173 events: Arc::clone(&events),
2174 };
2175
2176 let subscriber = tracing_subscriber::registry()
2177 .with(capture)
2178 .with(Targets::new().with_target(TELEMETRY_TARGET, tracing::Level::INFO));
2179 let _guard = tracing::subscriber::set_default(subscriber);
2180
2181 let mut palette = CommandPalette::new();
2185 palette.register("Alpha", None, &[]);
2186 tracing::callsite::rebuild_interest_cache();
2187 palette.open();
2188
2189 let a = Event::Key(KeyEvent {
2190 code: KeyCode::Char('a'),
2191 modifiers: Modifiers::empty(),
2192 kind: KeyEventKind::Press,
2193 });
2194 tracing::callsite::rebuild_interest_cache();
2195 let _ = palette.handle_event(&a);
2196
2197 let enter = Event::Key(KeyEvent {
2198 code: KeyCode::Enter,
2199 modifiers: Modifiers::empty(),
2200 kind: KeyEventKind::Press,
2201 });
2202 tracing::callsite::rebuild_interest_cache();
2203 let _ = palette.handle_event(&enter);
2204 tracing::callsite::rebuild_interest_cache();
2205 palette.close();
2206
2207 let events = events.lock().expect("lock telemetry events");
2208 let open_idx = events
2209 .iter()
2210 .position(|e| e == "palette_opened")
2211 .expect("palette_opened missing");
2212 let query_idx = events
2213 .iter()
2214 .position(|e| e == "palette_query_updated")
2215 .expect("palette_query_updated missing");
2216 let exec_idx = events
2217 .iter()
2218 .position(|e| e == "palette_action_executed")
2219 .expect("palette_action_executed missing");
2220 let close_idx = events
2221 .iter()
2222 .position(|e| e == "palette_closed")
2223 .expect("palette_closed missing");
2224
2225 assert!(open_idx < query_idx);
2226 assert!(query_idx < exec_idx);
2227 assert!(exec_idx < close_idx);
2228 }
2229
2230 #[test]
2235 fn compute_word_starts_empty() {
2236 let starts = compute_word_starts("");
2237 assert!(starts.is_empty());
2238 }
2239
2240 #[test]
2241 fn compute_word_starts_single_word() {
2242 let starts = compute_word_starts("hello");
2243 assert_eq!(starts, vec![0]);
2244 }
2245
2246 #[test]
2247 fn compute_word_starts_spaces() {
2248 let starts = compute_word_starts("open file now");
2249 assert_eq!(starts, vec![0, 5, 10]);
2250 }
2251
2252 #[test]
2253 fn compute_word_starts_hyphen_underscore() {
2254 let starts = compute_word_starts("git-commit_push");
2255 assert_eq!(starts, vec![0, 4, 11]);
2257 }
2258
2259 #[test]
2260 fn compute_word_starts_all_separators() {
2261 let starts = compute_word_starts("- _");
2262 assert_eq!(starts, vec![0, 1, 2]);
2265 }
2266
2267 #[test]
2268 fn backspace_on_empty_query_is_noop() {
2269 let mut palette = CommandPalette::new();
2270 palette.register("Alpha", None, &[]);
2271 palette.open();
2272 assert_eq!(palette.query(), "");
2273
2274 let bs = Event::Key(KeyEvent {
2275 code: KeyCode::Backspace,
2276 modifiers: Modifiers::empty(),
2277 kind: KeyEventKind::Press,
2278 });
2279 let _ = palette.handle_event(&bs);
2280 assert_eq!(palette.query(), "");
2281 assert_eq!(palette.result_count(), 1);
2283 }
2284
2285 #[test]
2286 fn ctrl_a_moves_cursor_to_start() {
2287 let mut palette = CommandPalette::new();
2288 palette.register("Alpha", None, &[]);
2289 palette.open();
2290
2291 for ch in "abc".chars() {
2293 let event = Event::Key(KeyEvent {
2294 code: KeyCode::Char(ch),
2295 modifiers: Modifiers::empty(),
2296 kind: KeyEventKind::Press,
2297 });
2298 let _ = palette.handle_event(&event);
2299 }
2300 assert_eq!(palette.query(), "abc");
2301
2302 let ctrl_a = Event::Key(KeyEvent {
2303 code: KeyCode::Char('a'),
2304 modifiers: Modifiers::CTRL,
2305 kind: KeyEventKind::Press,
2306 });
2307 let _ = palette.handle_event(&ctrl_a);
2308 assert_eq!(palette.query(), "abc");
2310 }
2311
2312 #[test]
2313 fn key_release_events_ignored() {
2314 let mut palette = CommandPalette::new();
2315 palette.register("Alpha", None, &[]);
2316 palette.open();
2317
2318 let release = Event::Key(KeyEvent {
2319 code: KeyCode::Char('x'),
2320 modifiers: Modifiers::empty(),
2321 kind: KeyEventKind::Release,
2322 });
2323 let result = palette.handle_event(&release);
2324 assert!(result.is_none());
2325 assert_eq!(palette.query(), "");
2326 }
2327
2328 #[test]
2329 fn resize_event_ignored() {
2330 let mut palette = CommandPalette::new();
2331 palette.open();
2332
2333 let resize = Event::Resize {
2334 width: 80,
2335 height: 24,
2336 };
2337 let result = palette.handle_event(&resize);
2338 assert!(result.is_none());
2339 }
2340
2341 #[test]
2342 fn is_essential_returns_true() {
2343 let palette = CommandPalette::new();
2344 assert!(palette.is_essential());
2345 }
2346
2347 #[test]
2348 fn render_too_small_area_noop() {
2349 use ftui_render::grapheme_pool::GraphemePool;
2350
2351 let mut palette = CommandPalette::new();
2352 palette.register("Alpha", None, &[]);
2353 palette.open();
2354
2355 let area = Rect::new(0, 0, 9, 10);
2357 let mut pool = GraphemePool::new();
2358 let mut frame = Frame::new(20, 20, &mut pool);
2359 palette.render(area, &mut frame);
2360 assert!(frame.cursor_position.is_none());
2362 }
2363
2364 #[test]
2365 fn render_too_short_area_noop() {
2366 use ftui_render::grapheme_pool::GraphemePool;
2367
2368 let mut palette = CommandPalette::new();
2369 palette.register("Alpha", None, &[]);
2370 palette.open();
2371
2372 let area = Rect::new(0, 0, 60, 4);
2374 let mut pool = GraphemePool::new();
2375 let mut frame = Frame::new(60, 10, &mut pool);
2376 palette.render(area, &mut frame);
2377 assert!(frame.cursor_position.is_none());
2378 }
2379
2380 #[test]
2381 fn render_hidden_palette_noop() {
2382 use ftui_render::grapheme_pool::GraphemePool;
2383
2384 let palette = CommandPalette::new();
2385 assert!(!palette.is_visible());
2386
2387 let area = Rect::from_size(60, 10);
2388 let mut pool = GraphemePool::new();
2389 let mut frame = Frame::new(60, 10, &mut pool);
2390 palette.render(area, &mut frame);
2391 assert!(frame.cursor_position.is_none());
2392 }
2393
2394 #[test]
2395 fn render_empty_palette_shows_no_actions_hint() {
2396 use ftui_render::grapheme_pool::GraphemePool;
2397
2398 let mut palette = CommandPalette::new();
2399 palette.open();
2401
2402 let area = Rect::from_size(60, 15);
2403 let mut pool = GraphemePool::new();
2404 let mut frame = Frame::new(60, 15, &mut pool);
2405 palette.render(area, &mut frame);
2406
2407 let palette_y = area.y + area.height / 6;
2409 let result_y = palette_y + 2;
2410 let mut found_n = false;
2411 for x in 0..60u16 {
2412 if let Some(cell) = frame.buffer.get(x, result_y)
2413 && cell.content.as_char() == Some('N')
2414 {
2415 found_n = true;
2416 break;
2417 }
2418 }
2419 assert!(found_n, "Should render 'No actions registered' hint");
2420 }
2421
2422 #[test]
2423 fn render_query_no_results_shows_hint() {
2424 use ftui_render::grapheme_pool::GraphemePool;
2425
2426 let mut palette = CommandPalette::new();
2427 palette.register("Alpha", None, &[]);
2428 palette.open();
2429 palette.set_query("zzzznotfound");
2430 assert_eq!(palette.result_count(), 0);
2431
2432 let area = Rect::from_size(60, 15);
2433 let mut pool = GraphemePool::new();
2434 let mut frame = Frame::new(60, 15, &mut pool);
2435 palette.render(area, &mut frame);
2436
2437 let palette_y = area.y + area.height / 6;
2439 let result_y = palette_y + 2;
2440 let mut found_n = false;
2441 for x in 0..60u16 {
2442 if let Some(cell) = frame.buffer.get(x, result_y)
2443 && cell.content.as_char() == Some('N')
2444 {
2445 found_n = true;
2446 break;
2447 }
2448 }
2449 assert!(found_n, "Should render 'No results' hint");
2450 }
2451
2452 #[test]
2453 fn render_with_category_badge() {
2454 use ftui_render::grapheme_pool::GraphemePool;
2455
2456 let mut palette = CommandPalette::new();
2457 let item = ActionItem::new("git_commit", "Commit Changes").with_category("Git");
2458 palette.register_action(item);
2459 palette.open();
2460
2461 let area = Rect::from_size(80, 15);
2462 let mut pool = GraphemePool::new();
2463 let mut frame = Frame::new(80, 15, &mut pool);
2464 palette.render(area, &mut frame);
2465
2466 let palette_y = area.y + area.height / 6;
2468 let result_y = palette_y + 2;
2469 let mut found_bracket = false;
2470 for x in 0..80u16 {
2471 if let Some(cell) = frame.buffer.get(x, result_y)
2472 && cell.content.as_char() == Some('[')
2473 {
2474 found_bracket = true;
2475 break;
2476 }
2477 }
2478 assert!(found_bracket, "Should render category badge '[Git]'");
2479 }
2480
2481 #[test]
2482 fn render_with_description_text() {
2483 use ftui_render::grapheme_pool::GraphemePool;
2484
2485 let mut palette = CommandPalette::new();
2486 palette.register("Open File", Some("Opens a file from disk"), &[]);
2487 palette.open();
2488
2489 let area = Rect::from_size(80, 15);
2490 let mut pool = GraphemePool::new();
2491 let mut frame = Frame::new(80, 15, &mut pool);
2492 palette.render(area, &mut frame);
2493
2494 let palette_y = area.y + area.height / 6;
2496 let result_y = palette_y + 2;
2497 let mut found_desc_char = false;
2498 for x in 20..80u16 {
2500 if let Some(cell) = frame.buffer.get(x, result_y)
2501 && cell.content.as_char() == Some('O')
2502 {
2503 found_desc_char = true;
2504 break;
2505 }
2506 }
2507 assert!(found_desc_char, "Description text should be rendered");
2508 }
2509
2510 #[test]
2511 fn open_resets_previous_state() {
2512 let mut palette = CommandPalette::new();
2513 palette.register("Alpha", None, &[]);
2514 palette.register("Beta", None, &[]);
2515 palette.open();
2516 palette.set_query("Alpha");
2517
2518 let down = Event::Key(KeyEvent {
2520 code: KeyCode::Down,
2521 modifiers: Modifiers::empty(),
2522 kind: KeyEventKind::Press,
2523 });
2524 let _ = palette.handle_event(&down);
2525
2526 palette.open();
2528 assert_eq!(palette.query(), "");
2529 assert_eq!(palette.selected_index(), 0);
2530 assert_eq!(palette.result_count(), 2);
2531 }
2532
2533 #[test]
2534 fn set_match_filter_same_value_is_noop() {
2535 let mut palette = CommandPalette::new();
2536 palette.register("Alpha", None, &[]);
2537 palette.open();
2538 palette.set_query("alpha");
2539
2540 palette.set_match_filter(MatchFilter::All);
2541 let count1 = palette.result_count();
2542 palette.set_match_filter(MatchFilter::All);
2544 assert_eq!(palette.result_count(), count1);
2545 }
2546
2547 #[test]
2548 fn generation_increments_on_register() {
2549 let mut palette = CommandPalette::new();
2550 palette.register("A", None, &[]);
2551 palette.register("B", None, &[]);
2552 palette.replace_actions(vec![ActionItem::new("c", "C")]);
2555 palette.open();
2556 assert_eq!(palette.action_count(), 1);
2557 }
2558
2559 #[test]
2560 fn enable_evidence_tracking_toggle() {
2561 let mut palette = CommandPalette::new();
2562 palette.register("Alpha", None, &[]);
2563 palette.open();
2564
2565 palette.enable_evidence_tracking(true);
2566 palette.set_query("alpha");
2567 assert!(palette.result_count() >= 1);
2568
2569 palette.enable_evidence_tracking(false);
2570 palette.set_query("alpha");
2571 assert!(palette.result_count() >= 1);
2572 }
2573
2574 #[test]
2575 fn register_chaining() {
2576 let mut palette = CommandPalette::new();
2577 palette
2578 .register("A", None, &[])
2579 .register("B", None, &[])
2580 .register("C", Some("desc"), &["tag"]);
2581 assert_eq!(palette.action_count(), 3);
2582 }
2583
2584 #[test]
2585 fn register_action_chaining() {
2586 let mut palette = CommandPalette::new();
2587 palette
2588 .register_action(ActionItem::new("a", "A"))
2589 .register_action(ActionItem::new("b", "B"));
2590 assert_eq!(palette.action_count(), 2);
2591 }
2592
2593 #[test]
2594 fn page_up_down_navigation() {
2595 let mut palette = CommandPalette::new().with_max_visible(3);
2596 for i in 0..10 {
2597 palette.register(format!("Action {i}"), None, &[]);
2598 }
2599 palette.open();
2600 assert_eq!(palette.selected_index(), 0);
2601
2602 let pgdn = Event::Key(KeyEvent {
2603 code: KeyCode::PageDown,
2604 modifiers: Modifiers::empty(),
2605 kind: KeyEventKind::Press,
2606 });
2607 let _ = palette.handle_event(&pgdn);
2608 assert_eq!(palette.selected_index(), 3); let _ = palette.handle_event(&pgdn);
2611 assert_eq!(palette.selected_index(), 6);
2612
2613 let _ = palette.handle_event(&pgdn);
2614 assert_eq!(palette.selected_index(), 9); let pgup = Event::Key(KeyEvent {
2617 code: KeyCode::PageUp,
2618 modifiers: Modifiers::empty(),
2619 kind: KeyEventKind::Press,
2620 });
2621 let _ = palette.handle_event(&pgup);
2622 assert_eq!(palette.selected_index(), 6); let _ = palette.handle_event(&pgup);
2625 assert_eq!(palette.selected_index(), 3);
2626
2627 let _ = palette.handle_event(&pgup);
2628 assert_eq!(palette.selected_index(), 0);
2629 }
2630
2631 #[test]
2632 fn page_down_empty_results_is_noop() {
2633 let mut palette = CommandPalette::new();
2634 palette.open();
2635 assert_eq!(palette.result_count(), 0);
2636
2637 let pgdn = Event::Key(KeyEvent {
2638 code: KeyCode::PageDown,
2639 modifiers: Modifiers::empty(),
2640 kind: KeyEventKind::Press,
2641 });
2642 let _ = palette.handle_event(&pgdn);
2643 assert_eq!(palette.selected_index(), 0);
2644 }
2645
2646 #[test]
2647 fn end_empty_results_is_noop() {
2648 let mut palette = CommandPalette::new();
2649 palette.open();
2650 assert_eq!(palette.result_count(), 0);
2651
2652 let end = Event::Key(KeyEvent {
2653 code: KeyCode::End,
2654 modifiers: Modifiers::empty(),
2655 kind: KeyEventKind::Press,
2656 });
2657 let _ = palette.handle_event(&end);
2658 assert_eq!(palette.selected_index(), 0);
2659 }
2660
2661 #[test]
2662 fn down_empty_results_is_noop() {
2663 let mut palette = CommandPalette::new();
2664 palette.open();
2665 assert_eq!(palette.result_count(), 0);
2666
2667 let down = Event::Key(KeyEvent {
2668 code: KeyCode::Down,
2669 modifiers: Modifiers::empty(),
2670 kind: KeyEventKind::Press,
2671 });
2672 let _ = palette.handle_event(&down);
2673 assert_eq!(palette.selected_index(), 0);
2674 }
2675
2676 #[test]
2677 fn selected_action_none_when_empty() {
2678 let mut palette = CommandPalette::new();
2679 palette.open();
2680 assert!(palette.selected_action().is_none());
2681 assert!(palette.selected_match().is_none());
2682 }
2683
2684 #[test]
2685 fn results_iterator_empty() {
2686 let mut palette = CommandPalette::new();
2687 palette.open();
2688 assert_eq!(palette.results().count(), 0);
2689 }
2690
2691 #[test]
2692 fn scroll_adjust_keeps_selection_visible() {
2693 let mut palette = CommandPalette::new().with_max_visible(3);
2694 for i in 0..10 {
2695 palette.register(format!("Action {i}"), None, &[]);
2696 }
2697 palette.open();
2698
2699 let end = Event::Key(KeyEvent {
2700 code: KeyCode::End,
2701 modifiers: Modifiers::empty(),
2702 kind: KeyEventKind::Press,
2703 });
2704 let _ = palette.handle_event(&end);
2705 assert_eq!(palette.selected_index(), 9);
2706 let home = Event::Key(KeyEvent {
2710 code: KeyCode::Home,
2711 modifiers: Modifiers::empty(),
2712 kind: KeyEventKind::Press,
2713 });
2714 let _ = palette.handle_event(&home);
2715 assert_eq!(palette.selected_index(), 0);
2716 }
2717
2718 #[test]
2719 fn action_item_clone() {
2720 let item = ActionItem::new("id", "Title")
2721 .with_description("Desc")
2722 .with_tags(&["a", "b"])
2723 .with_category("Cat");
2724 let cloned = item.clone();
2725 assert_eq!(cloned.id, "id");
2726 assert_eq!(cloned.title, "Title");
2727 assert_eq!(cloned.description.as_deref(), Some("Desc"));
2728 assert_eq!(cloned.tags, vec!["a", "b"]);
2729 assert_eq!(cloned.category.as_deref(), Some("Cat"));
2730 }
2731
2732 #[test]
2733 fn action_item_debug() {
2734 let item = ActionItem::new("id", "Title");
2735 let debug = format!("{:?}", item);
2736 assert!(debug.contains("ActionItem"));
2737 assert!(debug.contains("Title"));
2738 }
2739
2740 #[test]
2741 fn palette_action_clone_and_debug() {
2742 let exec = PaletteAction::Execute("test".into());
2743 let cloned = exec.clone();
2744 assert_eq!(exec, cloned);
2745
2746 let dismiss = PaletteAction::Dismiss;
2747 let debug = format!("{:?}", dismiss);
2748 assert!(debug.contains("Dismiss"));
2749 }
2750
2751 #[test]
2752 fn match_filter_traits() {
2753 let f = MatchFilter::Fuzzy;
2755 let debug = format!("{:?}", f);
2756 assert!(debug.contains("Fuzzy"));
2757
2758 let f2 = f;
2760 assert_eq!(f, f2);
2761
2762 assert_eq!(MatchFilter::All, MatchFilter::All);
2764 assert_ne!(MatchFilter::Exact, MatchFilter::Prefix);
2765 }
2766
2767 #[test]
2768 fn match_filter_specific_allows() {
2769 assert!(MatchFilter::Prefix.allows(MatchType::Prefix));
2770 assert!(!MatchFilter::Prefix.allows(MatchType::Exact));
2771 assert!(!MatchFilter::Prefix.allows(MatchType::Substring));
2772
2773 assert!(MatchFilter::WordStart.allows(MatchType::WordStart));
2774 assert!(!MatchFilter::WordStart.allows(MatchType::Fuzzy));
2775
2776 assert!(MatchFilter::Substring.allows(MatchType::Substring));
2777 assert!(!MatchFilter::Substring.allows(MatchType::WordStart));
2778 }
2779
2780 #[test]
2781 fn palette_style_default_has_all_colors() {
2782 let style = PaletteStyle::default();
2783 assert!(style.border.fg.is_some());
2784 assert!(style.input.fg.is_some());
2785 assert!(style.item.fg.is_some());
2786 assert!(style.item_selected.fg.is_some());
2787 assert!(style.item_selected.bg.is_some());
2788 assert!(style.match_highlight.fg.is_some());
2789 assert!(style.description.fg.is_some());
2790 assert!(style.category.fg.is_some());
2791 assert!(style.hint.fg.is_some());
2792 }
2793
2794 #[test]
2795 fn palette_style_debug_and_clone() {
2796 let style = PaletteStyle::default();
2797 let debug = format!("{:?}", style);
2798 assert!(debug.contains("PaletteStyle"));
2799
2800 let cloned = style.clone();
2801 assert_eq!(cloned.border.fg, style.border.fg);
2803 }
2804
2805 #[test]
2806 fn with_style_builder() {
2807 let style = PaletteStyle::default();
2808 let palette = CommandPalette::new().with_style(style);
2809 assert!(!palette.is_visible());
2811 }
2812
2813 #[test]
2814 fn command_palette_debug() {
2815 let mut palette = CommandPalette::new();
2816 palette.register("Alpha", None, &[]);
2817 let debug = format!("{:?}", palette);
2818 assert!(debug.contains("CommandPalette"));
2819 }
2820
2821 #[test]
2822 fn unrecognized_key_returns_none() {
2823 let mut palette = CommandPalette::new();
2824 palette.open();
2825
2826 let tab = Event::Key(KeyEvent {
2827 code: KeyCode::Tab,
2828 modifiers: Modifiers::empty(),
2829 kind: KeyEventKind::Press,
2830 });
2831 let result = palette.handle_event(&tab);
2832 assert!(result.is_none());
2833 }
2834
2835 #[test]
2836 fn ctrl_p_when_visible_does_not_reopen() {
2837 let mut palette = CommandPalette::new();
2838 palette.register("Alpha", None, &[]);
2839 palette.open();
2840 palette.set_query("test");
2841
2842 let ctrl_p = Event::Key(KeyEvent {
2844 code: KeyCode::Char('p'),
2845 modifiers: Modifiers::CTRL,
2846 kind: KeyEventKind::Press,
2847 });
2848 let _ = palette.handle_event(&ctrl_p);
2849 assert!(palette.is_visible());
2851 }
2852
2853 #[test]
2854 fn close_clears_query_and_results() {
2855 let mut palette = CommandPalette::new();
2856 palette.register("Alpha", None, &[]);
2857 palette.open();
2858 palette.set_query("alpha");
2859 assert!(!palette.query().is_empty());
2860 assert!(palette.result_count() > 0);
2861
2862 palette.close();
2863 assert!(!palette.is_visible());
2864 assert_eq!(palette.query(), "");
2865 assert_eq!(palette.result_count(), 0);
2866 }
2867
2868 #[test]
2869 fn render_cursor_position_set() {
2870 use ftui_render::grapheme_pool::GraphemePool;
2871
2872 let mut palette = CommandPalette::new();
2873 palette.register("Alpha", None, &[]);
2874 palette.open();
2875
2876 let area = Rect::from_size(60, 15);
2877 let mut pool = GraphemePool::new();
2878 let mut frame = Frame::new(60, 15, &mut pool);
2879 palette.render(area, &mut frame);
2880
2881 assert!(frame.cursor_position.is_some());
2882 assert!(frame.cursor_visible);
2883 }
2884
2885 #[test]
2886 fn render_many_items_with_scroll() {
2887 use ftui_render::grapheme_pool::GraphemePool;
2888
2889 let mut palette = CommandPalette::new().with_max_visible(3);
2890 for i in 0..20 {
2891 palette.register(format!("Action {i}"), None, &[]);
2892 }
2893 palette.open();
2894
2895 let end = Event::Key(KeyEvent {
2897 code: KeyCode::End,
2898 modifiers: Modifiers::empty(),
2899 kind: KeyEventKind::Press,
2900 });
2901 let _ = palette.handle_event(&end);
2902
2903 let area = Rect::from_size(60, 15);
2904 let mut pool = GraphemePool::new();
2905 let mut frame = Frame::new(60, 15, &mut pool);
2906 palette.render(area, &mut frame);
2908 assert!(frame.cursor_position.is_some());
2909 }
2910}
2911mod property_tests;