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 std::time::Instant;
55#[cfg(feature = "tracing")]
56use tracing::{debug, info};
57
58#[cfg(feature = "tracing")]
59const TELEMETRY_TARGET: &str = "ftui_widgets::command_palette";
60
61#[cfg(feature = "tracing")]
62fn telemetry_enabled() -> bool {
63 tracing::enabled!(target: TELEMETRY_TARGET, tracing::Level::INFO)
64}
65
66#[cfg(feature = "tracing")]
67fn emit_palette_opened(action_count: usize, result_count: usize) {
68 info!(
69 target: TELEMETRY_TARGET,
70 event = "palette_opened",
71 action_count,
72 result_count
73 );
74}
75
76#[cfg(feature = "tracing")]
77fn emit_palette_query_updated(query: &str, match_count: usize, latency_ms: u128) {
78 info!(
79 target: TELEMETRY_TARGET,
80 event = "palette_query_updated",
81 query_len = query.len(),
82 match_count,
83 latency_ms
84 );
85 if tracing::enabled!(target: TELEMETRY_TARGET, tracing::Level::DEBUG) {
86 debug!(
87 target: TELEMETRY_TARGET,
88 event = "palette_query_text",
89 query
90 );
91 }
92}
93
94#[cfg(feature = "tracing")]
95fn emit_palette_action_executed(action_id: &str, latency_ms: Option<u128>) {
96 if let Some(latency_ms) = latency_ms {
97 info!(
98 target: TELEMETRY_TARGET,
99 event = "palette_action_executed",
100 action_id,
101 latency_ms
102 );
103 } else {
104 info!(
105 target: TELEMETRY_TARGET,
106 event = "palette_action_executed",
107 action_id
108 );
109 }
110}
111
112#[cfg(feature = "tracing")]
113fn emit_palette_closed(reason: PaletteCloseReason) {
114 info!(
115 target: TELEMETRY_TARGET,
116 event = "palette_closed",
117 reason = reason.as_str()
118 );
119}
120
121#[derive(Debug, Clone)]
127pub struct ActionItem {
128 pub id: String,
130 pub title: String,
132 pub description: Option<String>,
134 pub tags: Vec<String>,
136 pub category: Option<String>,
138}
139
140impl ActionItem {
141 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
143 Self {
144 id: id.into(),
145 title: title.into(),
146 description: None,
147 tags: Vec::new(),
148 category: None,
149 }
150 }
151
152 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
154 self.description = Some(desc.into());
155 self
156 }
157
158 pub fn with_tags(mut self, tags: &[&str]) -> Self {
160 self.tags = tags.iter().map(|s| (*s).to_string()).collect();
161 self
162 }
163
164 pub fn with_category(mut self, cat: impl Into<String>) -> Self {
166 self.category = Some(cat.into());
167 self
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum PaletteAction {
178 Execute(String),
180 Dismiss,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum PaletteCloseReason {
186 Dismiss,
187 Execute,
188 Toggle,
189 Programmatic,
190}
191
192impl PaletteCloseReason {
193 #[cfg(feature = "tracing")]
194 const fn as_str(self) -> &'static str {
195 match self {
196 Self::Dismiss => "dismiss",
197 Self::Execute => "execute",
198 Self::Toggle => "toggle",
199 Self::Programmatic => "programmatic",
200 }
201 }
202}
203
204fn compute_word_starts(title_lower: &str) -> Vec<usize> {
205 let bytes = title_lower.as_bytes();
206 title_lower
207 .char_indices()
208 .filter_map(|(i, _)| {
209 let is_word_start = i == 0 || {
210 let prev = bytes.get(i.saturating_sub(1)).copied().unwrap_or(b' ');
211 prev == b' ' || prev == b'-' || prev == b'_'
212 };
213 is_word_start.then_some(i)
214 })
215 .collect()
216}
217
218#[derive(Debug, Clone)]
224pub struct PaletteStyle {
225 pub border: Style,
227 pub input: Style,
229 pub item: Style,
231 pub item_selected: Style,
233 pub match_highlight: Style,
235 pub description: Style,
237 pub category: Style,
239 pub hint: Style,
241}
242
243impl Default for PaletteStyle {
244 fn default() -> Self {
245 Self {
251 border: Style::new().fg(PackedRgba::rgb(100, 100, 120)),
252 input: Style::new().fg(PackedRgba::rgb(220, 220, 230)),
253 item: Style::new().fg(PackedRgba::rgb(190, 190, 200)),
254 item_selected: Style::new()
255 .fg(PackedRgba::rgb(255, 255, 255))
256 .bg(PackedRgba::rgb(50, 50, 75)),
257 match_highlight: Style::new().fg(PackedRgba::rgb(255, 210, 60)),
258 description: Style::new().fg(PackedRgba::rgb(140, 140, 160)),
259 category: Style::new().fg(PackedRgba::rgb(100, 180, 255)),
260 hint: Style::new().fg(PackedRgba::rgb(100, 100, 120)),
261 }
262 }
263}
264
265#[derive(Debug)]
271struct ScoredItem {
272 action_index: usize,
274 result: MatchResult,
276}
277
278#[derive(Debug, Clone, Copy)]
284pub struct PaletteMatch<'a> {
285 pub action: &'a ActionItem,
287 pub result: &'a MatchResult,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum MatchFilter {
298 All,
300 Exact,
302 Prefix,
304 WordStart,
306 Substring,
308 Fuzzy,
310}
311
312impl MatchFilter {
313 fn allows(self, match_type: MatchType) -> bool {
314 matches!(
315 (self, match_type),
316 (Self::All, _)
317 | (Self::Exact, MatchType::Exact)
318 | (Self::Prefix, MatchType::Prefix)
319 | (Self::WordStart, MatchType::WordStart)
320 | (Self::Substring, MatchType::Substring)
321 | (Self::Fuzzy, MatchType::Fuzzy)
322 )
323 }
324}
325
326#[derive(Debug)]
342pub struct CommandPalette {
343 actions: Vec<ActionItem>,
345 titles_cache: Vec<String>,
347 titles_lower: Vec<String>,
349 titles_word_starts: Vec<Vec<usize>>,
351 query: String,
353 cursor: usize,
355 selected: usize,
357 scroll_offset: usize,
359 visible: bool,
361 style: PaletteStyle,
363 scorer: IncrementalScorer,
365 filtered: Vec<ScoredItem>,
367 match_filter: MatchFilter,
369 generation: u64,
371 max_visible: usize,
373 #[cfg(feature = "tracing")]
375 opened_at: Option<Instant>,
376}
377
378impl Default for CommandPalette {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl CommandPalette {
385 pub fn new() -> Self {
387 Self {
388 actions: Vec::new(),
389 titles_cache: Vec::new(),
390 titles_lower: Vec::new(),
391 titles_word_starts: Vec::new(),
392 query: String::new(),
393 cursor: 0,
394 selected: 0,
395 scroll_offset: 0,
396 visible: false,
397 style: PaletteStyle::default(),
398 scorer: IncrementalScorer::new(),
399 filtered: Vec::new(),
400 match_filter: MatchFilter::All,
401 generation: 0,
402 max_visible: 10,
403 #[cfg(feature = "tracing")]
404 opened_at: None,
405 }
406 }
407
408 pub fn with_style(mut self, style: PaletteStyle) -> Self {
410 self.style = style;
411 self
412 }
413
414 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 emit_palette_opened(self.actions.len(), self.filtered.len());
543 }
544
545 pub fn close(&mut self) {
547 self.close_with_reason(PaletteCloseReason::Programmatic);
548 }
549
550 pub fn toggle(&mut self) {
552 if self.visible {
553 self.close_with_reason(PaletteCloseReason::Toggle);
554 } else {
555 self.open();
556 }
557 }
558
559 pub fn is_visible(&self) -> bool {
561 self.visible
562 }
563
564 pub fn query(&self) -> &str {
568 &self.query
569 }
570
571 pub fn set_query(&mut self, query: impl Into<String>) {
573 self.query = query.into();
574 self.cursor = self.query.len();
575 self.selected = 0;
576 self.scroll_offset = 0;
577 self.scorer.invalidate();
578 self.update_filtered(false);
579 }
580
581 pub fn result_count(&self) -> usize {
583 self.filtered.len()
584 }
585
586 pub fn selected_index(&self) -> usize {
588 self.selected
589 }
590
591 pub fn selected_action(&self) -> Option<&ActionItem> {
593 self.filtered
594 .get(self.selected)
595 .map(|si| &self.actions[si.action_index])
596 }
597
598 pub fn selected_match(&self) -> Option<PaletteMatch<'_>> {
600 self.filtered.get(self.selected).map(|si| PaletteMatch {
601 action: &self.actions[si.action_index],
602 result: &si.result,
603 })
604 }
605
606 pub fn results(&self) -> impl Iterator<Item = PaletteMatch<'_>> {
608 self.filtered.iter().map(|si| PaletteMatch {
609 action: &self.actions[si.action_index],
610 result: &si.result,
611 })
612 }
613
614 pub fn set_match_filter(&mut self, filter: MatchFilter) {
616 if self.match_filter == filter {
617 return;
618 }
619 self.match_filter = filter;
620 self.selected = 0;
621 self.scroll_offset = 0;
622 self.update_filtered(false);
623 }
624
625 pub fn scorer_stats(&self) -> &IncrementalStats {
627 self.scorer.stats()
628 }
629
630 pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
638 if !self.visible {
639 if let Event::Key(KeyEvent {
641 code: KeyCode::Char('p'),
642 modifiers,
643 kind: KeyEventKind::Press,
644 }) = event
645 && modifiers.contains(Modifiers::CTRL)
646 {
647 self.open();
648 }
649 return None;
650 }
651
652 match event {
653 Event::Key(KeyEvent {
654 code,
655 modifiers,
656 kind: KeyEventKind::Press,
657 }) => self.handle_key(*code, *modifiers),
658 _ => None,
659 }
660 }
661
662 fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Option<PaletteAction> {
664 match code {
665 KeyCode::Escape => {
666 self.close_with_reason(PaletteCloseReason::Dismiss);
667 return Some(PaletteAction::Dismiss);
668 }
669
670 KeyCode::Enter => {
671 if let Some(si) = self.filtered.get(self.selected) {
672 let id = self.actions[si.action_index].id.clone();
673 #[cfg(feature = "tracing")]
674 {
675 let latency_ms = self.opened_at.map(|start| start.elapsed().as_millis());
676 emit_palette_action_executed(&id, latency_ms);
677 }
678 self.close_with_reason(PaletteCloseReason::Execute);
679 return Some(PaletteAction::Execute(id));
680 }
681 }
682
683 KeyCode::Up => {
684 if self.selected > 0 {
685 self.selected -= 1;
686 self.adjust_scroll();
687 }
688 }
689
690 KeyCode::Down => {
691 if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 {
692 self.selected += 1;
693 self.adjust_scroll();
694 }
695 }
696
697 KeyCode::PageUp => {
698 self.selected = self.selected.saturating_sub(self.max_visible);
699 self.adjust_scroll();
700 }
701
702 KeyCode::PageDown => {
703 if !self.filtered.is_empty() {
704 self.selected = (self.selected + self.max_visible).min(self.filtered.len() - 1);
705 self.adjust_scroll();
706 }
707 }
708
709 KeyCode::Home => {
710 self.selected = 0;
711 self.scroll_offset = 0;
712 }
713
714 KeyCode::End => {
715 if !self.filtered.is_empty() {
716 self.selected = self.filtered.len() - 1;
717 self.adjust_scroll();
718 }
719 }
720
721 KeyCode::Backspace => {
722 if !self.query.is_empty() {
723 self.query.pop();
725 self.cursor = self.query.len();
726 self.selected = 0;
727 self.scroll_offset = 0;
728 self.update_filtered(true);
729 }
730 }
731
732 KeyCode::Char(c) => {
733 if modifiers.contains(Modifiers::CTRL) {
734 if c == 'a' {
736 self.cursor = 0;
737 }
738 if c == 'u' {
740 self.query.clear();
741 self.cursor = 0;
742 self.selected = 0;
743 self.scroll_offset = 0;
744 self.update_filtered(true);
745 }
746 } else {
747 self.query.push(c);
748 self.cursor = self.query.len();
749 self.selected = 0;
750 self.scroll_offset = 0;
751 self.update_filtered(true);
752 }
753 }
754
755 _ => {}
756 }
757
758 None
759 }
760
761 fn update_filtered(&mut self, _emit_telemetry: bool) {
763 #[cfg(feature = "tracing")]
764 let start = if _emit_telemetry && telemetry_enabled() {
765 Some(Instant::now())
766 } else {
767 None
768 };
769
770 if self.titles_cache.len() != self.actions.len()
771 || self.titles_lower.len() != self.actions.len()
772 || self.titles_word_starts.len() != self.actions.len()
773 {
774 self.rebuild_title_cache();
775 }
776
777 let results = self.scorer.score_corpus_with_lowered_and_words(
778 &self.query,
779 &self.titles_cache,
780 &self.titles_lower,
781 &self.titles_word_starts,
782 Some(self.generation),
783 );
784
785 self.filtered = results
786 .into_iter()
787 .filter(|(_, result)| self.match_filter.allows(result.match_type))
788 .map(|(idx, result)| ScoredItem {
789 action_index: idx,
790 result,
791 })
792 .collect();
793
794 if !self.filtered.is_empty() {
796 self.selected = self.selected.min(self.filtered.len() - 1);
797 } else {
798 self.selected = 0;
799 }
800
801 #[cfg(feature = "tracing")]
802 if let Some(start) = start {
803 emit_palette_query_updated(
804 &self.query,
805 self.filtered.len(),
806 start.elapsed().as_millis(),
807 );
808 }
809 }
810
811 fn close_with_reason(&mut self, _reason: PaletteCloseReason) {
812 self.visible = false;
813 self.query.clear();
814 self.cursor = 0;
815 self.filtered.clear();
816 #[cfg(feature = "tracing")]
817 {
818 self.opened_at = None;
819 emit_palette_closed(_reason);
820 }
821 }
822
823 fn adjust_scroll(&mut self) {
825 if self.selected < self.scroll_offset {
826 self.scroll_offset = self.selected;
827 } else if self.selected >= self.scroll_offset + self.max_visible {
828 self.scroll_offset = self.selected + 1 - self.max_visible;
829 }
830 }
831}
832
833impl Widget for CommandPalette {
838 fn render(&self, area: Rect, frame: &mut Frame) {
839 if !self.visible || area.width < 10 || area.height < 5 {
840 return;
841 }
842
843 let palette_width = (area.width * 3 / 5).max(30).min(area.width - 2);
845 let result_rows = self.filtered.len().min(self.max_visible);
846 let palette_height = (result_rows as u16 + 3)
848 .max(5)
849 .min(area.height.saturating_sub(2));
850 let palette_x = area.x + (area.width.saturating_sub(palette_width)) / 2;
851 let palette_y = area.y + area.height / 6; let palette_area = Rect::new(palette_x, palette_y, palette_width, palette_height);
854
855 self.clear_area(palette_area, frame);
857
858 self.draw_border(palette_area, frame);
860
861 let input_area = Rect::new(
863 palette_area.x + 2,
864 palette_area.y + 1,
865 palette_area.width.saturating_sub(4),
866 1,
867 );
868 self.draw_query_input(input_area, frame);
869
870 let results_y = palette_area.y + 2;
872 let results_height = palette_area.height.saturating_sub(3);
873 let results_area = Rect::new(
874 palette_area.x + 1,
875 results_y,
876 palette_area.width.saturating_sub(2),
877 results_height,
878 );
879 self.draw_results(results_area, frame);
880
881 let cursor_visual_pos = display_width(&self.query[..self.cursor.min(self.query.len())]);
885 let cursor_x = input_area.x + cursor_visual_pos.min(input_area.width as usize) as u16;
886 frame.cursor_position = Some((cursor_x, input_area.y));
887 frame.cursor_visible = true;
888 }
889
890 fn is_essential(&self) -> bool {
891 true
892 }
893}
894
895impl CommandPalette {
896 fn clear_area(&self, area: Rect, frame: &mut Frame) {
898 let bg = PackedRgba::rgb(30, 30, 40);
899 for y in area.y..area.bottom() {
900 for x in area.x..area.right() {
901 if let Some(cell) = frame.buffer.get_mut(x, y) {
902 *cell = Cell::from_char(' ');
903 cell.bg = bg;
904 }
905 }
906 }
907 }
908
909 fn draw_border(&self, area: Rect, frame: &mut Frame) {
911 let border_fg = self
912 .style
913 .border
914 .fg
915 .unwrap_or(PackedRgba::rgb(100, 100, 120));
916 let bg = PackedRgba::rgb(30, 30, 40);
917
918 if let Some(cell) = frame.buffer.get_mut(area.x, area.y) {
920 cell.content = CellContent::from_char('┌');
921 cell.fg = border_fg;
922 cell.bg = bg;
923 }
924 for x in (area.x + 1)..area.right().saturating_sub(1) {
925 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
926 cell.content = CellContent::from_char('─');
927 cell.fg = border_fg;
928 cell.bg = bg;
929 }
930 }
931 if area.width > 1
932 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, area.y)
933 {
934 cell.content = CellContent::from_char('┐');
935 cell.fg = border_fg;
936 cell.bg = bg;
937 }
938
939 let title = " Command Palette ";
941 let title_width = display_width(title).min(area.width as usize);
942 let title_x = area.x + (area.width.saturating_sub(title_width as u16)) / 2;
943 let title_style = Style::new().fg(PackedRgba::rgb(200, 200, 220)).bg(bg);
944 crate::draw_text_span(frame, title_x, area.y, title, title_style, area.right());
945
946 for y in (area.y + 1)..area.bottom().saturating_sub(1) {
948 if let Some(cell) = frame.buffer.get_mut(area.x, y) {
949 cell.content = CellContent::from_char('│');
950 cell.fg = border_fg;
951 cell.bg = bg;
952 }
953 if area.width > 1
954 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, y)
955 {
956 cell.content = CellContent::from_char('│');
957 cell.fg = border_fg;
958 cell.bg = bg;
959 }
960 }
961
962 if area.height > 1 {
964 let by = area.bottom() - 1;
965 if let Some(cell) = frame.buffer.get_mut(area.x, by) {
966 cell.content = CellContent::from_char('└');
967 cell.fg = border_fg;
968 cell.bg = bg;
969 }
970 for x in (area.x + 1)..area.right().saturating_sub(1) {
971 if let Some(cell) = frame.buffer.get_mut(x, by) {
972 cell.content = CellContent::from_char('─');
973 cell.fg = border_fg;
974 cell.bg = bg;
975 }
976 }
977 if area.width > 1
978 && let Some(cell) = frame.buffer.get_mut(area.right() - 1, by)
979 {
980 cell.content = CellContent::from_char('┘');
981 cell.fg = border_fg;
982 cell.bg = bg;
983 }
984 }
985 }
986
987 fn draw_query_input(&self, area: Rect, frame: &mut Frame) {
989 let input_fg = self
990 .style
991 .input
992 .fg
993 .unwrap_or(PackedRgba::rgb(220, 220, 230));
994 let bg = PackedRgba::rgb(30, 30, 40);
995 let prompt_fg = PackedRgba::rgb(100, 180, 255);
996
997 if let Some(cell) = frame.buffer.get_mut(area.x.saturating_sub(1), area.y) {
999 cell.content = CellContent::from_char('>');
1000 cell.fg = prompt_fg;
1001 cell.bg = bg;
1002 }
1003
1004 if self.query.is_empty() {
1006 let hint = "Type to search...";
1008 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1009 for (i, ch) in hint.chars().enumerate() {
1010 let x = area.x + i as u16;
1011 if x >= area.right() {
1012 break;
1013 }
1014 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1015 cell.content = CellContent::from_char(ch);
1016 cell.fg = hint_fg;
1017 cell.bg = bg;
1018 }
1019 }
1020 } else {
1021 let mut col = area.x;
1023 for grapheme in graphemes(&self.query) {
1024 let w = grapheme_width(grapheme);
1025 if w == 0 {
1026 continue;
1027 }
1028 if col >= area.right() {
1029 break;
1030 }
1031 if col.saturating_add(w as u16) > area.right() {
1032 break;
1033 }
1034 let content = if w > 1 || grapheme.chars().count() > 1 {
1035 let id = frame.intern_with_width(grapheme, w as u8);
1036 CellContent::from_grapheme(id)
1037 } else if let Some(ch) = grapheme.chars().next() {
1038 CellContent::from_char(ch)
1039 } else {
1040 continue;
1041 };
1042 if let Some(cell) = frame.buffer.get_mut(col, area.y) {
1043 cell.content = content;
1044 cell.fg = input_fg;
1045 cell.bg = bg;
1046 }
1047 col = col.saturating_add(w as u16);
1048 }
1049 }
1050 }
1051
1052 fn draw_results(&self, area: Rect, frame: &mut Frame) {
1054 if self.filtered.is_empty() {
1055 let msg = if self.query.is_empty() {
1057 "No actions registered"
1058 } else {
1059 "No results"
1060 };
1061 let hint_fg = self.style.hint.fg.unwrap_or(PackedRgba::rgb(100, 100, 120));
1062 let bg = PackedRgba::rgb(30, 30, 40);
1063 for (i, ch) in msg.chars().enumerate() {
1064 let x = area.x + 1 + i as u16;
1065 if x >= area.right() {
1066 break;
1067 }
1068 if let Some(cell) = frame.buffer.get_mut(x, area.y) {
1069 cell.content = CellContent::from_char(ch);
1070 cell.fg = hint_fg;
1071 cell.bg = bg;
1072 }
1073 }
1074 return;
1075 }
1076
1077 let item_fg = self.style.item.fg.unwrap_or(PackedRgba::rgb(180, 180, 190));
1078 let selected_fg = self
1079 .style
1080 .item_selected
1081 .fg
1082 .unwrap_or(PackedRgba::rgb(255, 255, 255));
1083 let selected_bg = self
1084 .style
1085 .item_selected
1086 .bg
1087 .unwrap_or(PackedRgba::rgb(60, 60, 80));
1088 let highlight_fg = self
1089 .style
1090 .match_highlight
1091 .fg
1092 .unwrap_or(PackedRgba::rgb(255, 200, 50));
1093 let desc_fg = self
1094 .style
1095 .description
1096 .fg
1097 .unwrap_or(PackedRgba::rgb(120, 120, 140));
1098 let cat_fg = self
1099 .style
1100 .category
1101 .fg
1102 .unwrap_or(PackedRgba::rgb(100, 180, 255));
1103 let bg = PackedRgba::rgb(30, 30, 40);
1104
1105 let visible_end = (self.scroll_offset + area.height as usize).min(self.filtered.len());
1106
1107 for (row_idx, si) in self.filtered[self.scroll_offset..visible_end]
1108 .iter()
1109 .enumerate()
1110 {
1111 let y = area.y + row_idx as u16;
1112 if y >= area.bottom() {
1113 break;
1114 }
1115
1116 let action = &self.actions[si.action_index];
1117 let is_selected = (self.scroll_offset + row_idx) == self.selected;
1118
1119 let row_fg = if is_selected { selected_fg } else { item_fg };
1120 let row_bg = if is_selected { selected_bg } else { bg };
1121
1122 let row_attrs = if is_selected {
1124 CellAttrs::new(CellStyleFlags::BOLD, 0)
1125 } else {
1126 CellAttrs::default()
1127 };
1128
1129 for x in area.x..area.right() {
1131 if let Some(cell) = frame.buffer.get_mut(x, y) {
1132 cell.content = CellContent::from_char(' ');
1133 cell.fg = row_fg;
1134 cell.bg = row_bg;
1135 cell.attrs = row_attrs;
1136 }
1137 }
1138
1139 let mut col = area.x;
1141 if is_selected && let Some(cell) = frame.buffer.get_mut(col, y) {
1142 cell.content = CellContent::from_char('>');
1143 cell.fg = highlight_fg;
1144 cell.bg = row_bg;
1145 cell.attrs = CellAttrs::new(CellStyleFlags::BOLD, 0);
1146 }
1147 col += 2;
1148
1149 if let Some(ref cat) = action.category {
1151 let badge = format!("[{}] ", cat);
1152 for grapheme in graphemes(&badge) {
1153 let w = grapheme_width(grapheme);
1154 if w == 0 {
1155 continue;
1156 }
1157 if col >= area.right() || col.saturating_add(w as u16) > area.right() {
1158 break;
1159 }
1160 let content = if w > 1 || grapheme.chars().count() > 1 {
1161 let id = frame.intern_with_width(grapheme, w as u8);
1162 CellContent::from_grapheme(id)
1163 } else if let Some(ch) = grapheme.chars().next() {
1164 CellContent::from_char(ch)
1165 } else {
1166 continue;
1167 };
1168 let mut cell = Cell::new(content);
1169 cell.fg = cat_fg;
1170 cell.bg = row_bg;
1171 cell.attrs = row_attrs;
1172 frame.buffer.set(col, y, cell);
1173 col = col.saturating_add(w as u16);
1174 }
1175 }
1176
1177 let title_max_width = area.right().saturating_sub(col) as usize;
1179 let title_width = display_width(action.title.as_str());
1180 let needs_ellipsis = title_width > title_max_width && title_max_width > 3;
1181 let title_display_width = if needs_ellipsis {
1182 title_max_width.saturating_sub(1) } else {
1184 title_max_width
1185 };
1186
1187 let mut title_used_width = 0usize;
1188 let mut char_idx = 0usize;
1189 let mut match_cursor = 0usize;
1190 let match_positions = &si.result.match_positions;
1191 for grapheme in graphemes(action.title.as_str()) {
1192 let g_chars = grapheme.chars().count();
1193 let char_end = char_idx + g_chars;
1194 while match_cursor < match_positions.len()
1195 && match_positions[match_cursor] < char_idx
1196 {
1197 match_cursor += 1;
1198 }
1199 let is_match = match_cursor < match_positions.len()
1200 && match_positions[match_cursor] < char_end;
1201
1202 let w = grapheme_width(grapheme);
1203 if w == 0 {
1204 char_idx = char_end;
1205 continue;
1206 }
1207 if title_used_width + w > title_display_width || col >= area.right() {
1208 break;
1209 }
1210 if col.saturating_add(w as u16) > area.right() {
1211 break;
1212 }
1213
1214 let content = if w > 1 || grapheme.chars().count() > 1 {
1215 let id = frame.intern_with_width(grapheme, w as u8);
1216 CellContent::from_grapheme(id)
1217 } else if let Some(ch) = grapheme.chars().next() {
1218 CellContent::from_char(ch)
1219 } else {
1220 char_idx = char_end;
1221 continue;
1222 };
1223
1224 let mut cell = Cell::new(content);
1225 cell.fg = if is_match { highlight_fg } else { row_fg };
1226 cell.bg = row_bg;
1227 cell.attrs = row_attrs;
1228 frame.buffer.set(col, y, cell);
1229
1230 col = col.saturating_add(w as u16);
1231 title_used_width += w;
1232 char_idx = char_end;
1233 }
1234
1235 if needs_ellipsis && col < area.right() {
1237 if let Some(cell) = frame.buffer.get_mut(col, y) {
1238 cell.content = CellContent::from_char('\u{2026}'); cell.fg = row_fg;
1240 cell.bg = row_bg;
1241 cell.attrs = row_attrs;
1242 }
1243 col += 1;
1244 }
1245
1246 if let Some(ref desc) = action.description {
1248 col += 2; let max_desc_width = area.right().saturating_sub(col) as usize;
1250 if max_desc_width > 5 {
1251 let desc_width = display_width(desc.as_str());
1252 let desc_needs_ellipsis = desc_width > max_desc_width && max_desc_width > 3;
1253 let desc_display_width = if desc_needs_ellipsis {
1254 max_desc_width.saturating_sub(1)
1255 } else {
1256 max_desc_width
1257 };
1258
1259 let mut desc_used_width = 0usize;
1260 for grapheme in graphemes(desc.as_str()) {
1261 let w = grapheme_width(grapheme);
1262 if w == 0 {
1263 continue;
1264 }
1265 if desc_used_width + w > desc_display_width || col >= area.right() {
1266 break;
1267 }
1268 if 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 = desc_fg;
1281 cell.bg = row_bg;
1282 cell.attrs = row_attrs;
1283 frame.buffer.set(col, y, cell);
1284 col = col.saturating_add(w as u16);
1285 desc_used_width += w;
1286 }
1287
1288 if desc_needs_ellipsis
1289 && col < area.right()
1290 && let Some(cell) = frame.buffer.get_mut(col, y)
1291 {
1292 cell.content = CellContent::from_char('\u{2026}');
1293 cell.fg = desc_fg;
1294 cell.bg = row_bg;
1295 cell.attrs = row_attrs;
1296 }
1297 }
1298 }
1299 }
1300 }
1301}
1302
1303#[cfg(test)]
1308mod widget_tests {
1309 use super::*;
1310
1311 #[test]
1312 fn new_palette_is_hidden() {
1313 let palette = CommandPalette::new();
1314 assert!(!palette.is_visible());
1315 assert_eq!(palette.action_count(), 0);
1316 }
1317
1318 #[test]
1319 fn register_actions() {
1320 let mut palette = CommandPalette::new();
1321 palette.register("Open File", Some("Open a file"), &["file"]);
1322 palette.register("Save File", None, &[]);
1323 assert_eq!(palette.action_count(), 2);
1324 }
1325
1326 #[test]
1327 fn open_shows_all_actions() {
1328 let mut palette = CommandPalette::new();
1329 palette.register("Open File", None, &[]);
1330 palette.register("Save File", None, &[]);
1331 palette.register("Close Tab", None, &[]);
1332 palette.open();
1333 assert!(palette.is_visible());
1334 assert_eq!(palette.result_count(), 3);
1335 }
1336
1337 #[test]
1338 fn close_hides_palette() {
1339 let mut palette = CommandPalette::new();
1340 palette.open();
1341 assert!(palette.is_visible());
1342 palette.close();
1343 assert!(!palette.is_visible());
1344 }
1345
1346 #[test]
1347 fn toggle_visibility() {
1348 let mut palette = CommandPalette::new();
1349 palette.toggle();
1350 assert!(palette.is_visible());
1351 palette.toggle();
1352 assert!(!palette.is_visible());
1353 }
1354
1355 #[test]
1356 fn typing_filters_results() {
1357 let mut palette = CommandPalette::new();
1358 palette.register("Open File", None, &[]);
1359 palette.register("Save File", None, &[]);
1360 palette.register("Git: Commit", None, &[]);
1361 palette.open();
1362 assert_eq!(palette.result_count(), 3);
1363
1364 let g = Event::Key(KeyEvent {
1366 code: KeyCode::Char('g'),
1367 modifiers: Modifiers::empty(),
1368 kind: KeyEventKind::Press,
1369 });
1370 let i = Event::Key(KeyEvent {
1371 code: KeyCode::Char('i'),
1372 modifiers: Modifiers::empty(),
1373 kind: KeyEventKind::Press,
1374 });
1375 let t = Event::Key(KeyEvent {
1376 code: KeyCode::Char('t'),
1377 modifiers: Modifiers::empty(),
1378 kind: KeyEventKind::Press,
1379 });
1380
1381 palette.handle_event(&g);
1382 palette.handle_event(&i);
1383 palette.handle_event(&t);
1384
1385 assert_eq!(palette.query(), "git");
1386 assert!(palette.result_count() >= 1);
1388 }
1389
1390 #[test]
1391 fn backspace_removes_character() {
1392 let mut palette = CommandPalette::new();
1393 palette.register("Open File", None, &[]);
1394 palette.open();
1395
1396 let o = Event::Key(KeyEvent {
1397 code: KeyCode::Char('o'),
1398 modifiers: Modifiers::empty(),
1399 kind: KeyEventKind::Press,
1400 });
1401 let bs = Event::Key(KeyEvent {
1402 code: KeyCode::Backspace,
1403 modifiers: Modifiers::empty(),
1404 kind: KeyEventKind::Press,
1405 });
1406
1407 palette.handle_event(&o);
1408 assert_eq!(palette.query(), "o");
1409 palette.handle_event(&bs);
1410 assert_eq!(palette.query(), "");
1411 }
1412
1413 #[test]
1414 fn esc_dismisses_palette() {
1415 let mut palette = CommandPalette::new();
1416 palette.open();
1417
1418 let esc = Event::Key(KeyEvent {
1419 code: KeyCode::Escape,
1420 modifiers: Modifiers::empty(),
1421 kind: KeyEventKind::Press,
1422 });
1423
1424 let result = palette.handle_event(&esc);
1425 assert_eq!(result, Some(PaletteAction::Dismiss));
1426 assert!(!palette.is_visible());
1427 }
1428
1429 #[test]
1430 fn enter_executes_selected() {
1431 let mut palette = CommandPalette::new();
1432 palette.register("Open File", None, &[]);
1433 palette.open();
1434
1435 let enter = Event::Key(KeyEvent {
1436 code: KeyCode::Enter,
1437 modifiers: Modifiers::empty(),
1438 kind: KeyEventKind::Press,
1439 });
1440
1441 let result = palette.handle_event(&enter);
1442 assert_eq!(result, Some(PaletteAction::Execute("open_file".into())));
1443 }
1444
1445 #[test]
1446 fn arrow_keys_navigate() {
1447 let mut palette = CommandPalette::new();
1448 palette.register("A", None, &[]);
1449 palette.register("B", None, &[]);
1450 palette.register("C", None, &[]);
1451 palette.open();
1452
1453 assert_eq!(palette.selected_index(), 0);
1454
1455 let down = Event::Key(KeyEvent {
1456 code: KeyCode::Down,
1457 modifiers: Modifiers::empty(),
1458 kind: KeyEventKind::Press,
1459 });
1460 let up = Event::Key(KeyEvent {
1461 code: KeyCode::Up,
1462 modifiers: Modifiers::empty(),
1463 kind: KeyEventKind::Press,
1464 });
1465
1466 palette.handle_event(&down);
1467 assert_eq!(palette.selected_index(), 1);
1468 palette.handle_event(&down);
1469 assert_eq!(palette.selected_index(), 2);
1470 palette.handle_event(&down);
1472 assert_eq!(palette.selected_index(), 2);
1473
1474 palette.handle_event(&up);
1475 assert_eq!(palette.selected_index(), 1);
1476 palette.handle_event(&up);
1477 assert_eq!(palette.selected_index(), 0);
1478 palette.handle_event(&up);
1480 assert_eq!(palette.selected_index(), 0);
1481 }
1482
1483 #[test]
1484 fn home_end_navigation() {
1485 let mut palette = CommandPalette::new();
1486 for i in 0..20 {
1487 palette.register(format!("Action {}", i), None, &[]);
1488 }
1489 palette.open();
1490
1491 let end = Event::Key(KeyEvent {
1492 code: KeyCode::End,
1493 modifiers: Modifiers::empty(),
1494 kind: KeyEventKind::Press,
1495 });
1496 let home = Event::Key(KeyEvent {
1497 code: KeyCode::Home,
1498 modifiers: Modifiers::empty(),
1499 kind: KeyEventKind::Press,
1500 });
1501
1502 palette.handle_event(&end);
1503 assert_eq!(palette.selected_index(), 19);
1504
1505 palette.handle_event(&home);
1506 assert_eq!(palette.selected_index(), 0);
1507 }
1508
1509 #[test]
1510 fn ctrl_u_clears_query() {
1511 let mut palette = CommandPalette::new();
1512 palette.register("Open File", None, &[]);
1513 palette.open();
1514
1515 let o = Event::Key(KeyEvent {
1516 code: KeyCode::Char('o'),
1517 modifiers: Modifiers::empty(),
1518 kind: KeyEventKind::Press,
1519 });
1520 palette.handle_event(&o);
1521 assert_eq!(palette.query(), "o");
1522
1523 let ctrl_u = Event::Key(KeyEvent {
1524 code: KeyCode::Char('u'),
1525 modifiers: Modifiers::CTRL,
1526 kind: KeyEventKind::Press,
1527 });
1528 palette.handle_event(&ctrl_u);
1529 assert_eq!(palette.query(), "");
1530 }
1531
1532 #[test]
1533 fn ctrl_p_opens_palette() {
1534 let mut palette = CommandPalette::new();
1535 assert!(!palette.is_visible());
1536
1537 let ctrl_p = Event::Key(KeyEvent {
1538 code: KeyCode::Char('p'),
1539 modifiers: Modifiers::CTRL,
1540 kind: KeyEventKind::Press,
1541 });
1542 palette.handle_event(&ctrl_p);
1543 assert!(palette.is_visible());
1544 }
1545
1546 #[test]
1547 fn selected_action_returns_correct_item() {
1548 let mut palette = CommandPalette::new();
1549 palette.register("Alpha", None, &[]);
1550 palette.register("Beta", None, &[]);
1551 palette.open();
1552
1553 let action = palette.selected_action().unwrap();
1554 assert!(!action.title.is_empty());
1556 }
1557
1558 #[test]
1559 fn register_action_item_directly() {
1560 let mut palette = CommandPalette::new();
1561 let item = ActionItem::new("custom_id", "Custom Action")
1562 .with_description("A custom action")
1563 .with_tags(&["custom", "test"])
1564 .with_category("Testing");
1565
1566 palette.register_action(item);
1567 assert_eq!(palette.action_count(), 1);
1568 }
1569
1570 #[test]
1571 fn replace_actions_refreshes_results() {
1572 let mut palette = CommandPalette::new();
1573 palette.register("Alpha", None, &[]);
1574 palette.register("Beta", None, &[]);
1575 palette.open();
1576 palette.set_query("Beta");
1577 assert_eq!(
1578 palette.selected_action().map(|a| a.title.as_str()),
1579 Some("Beta")
1580 );
1581
1582 let actions = vec![
1583 ActionItem::new("gamma", "Gamma"),
1584 ActionItem::new("delta", "Delta"),
1585 ];
1586 palette.replace_actions(actions);
1587 palette.set_query("Delta");
1588 assert_eq!(
1589 palette.selected_action().map(|a| a.title.as_str()),
1590 Some("Delta")
1591 );
1592 }
1593
1594 #[test]
1595 fn clear_actions_resets_results() {
1596 let mut palette = CommandPalette::new();
1597 palette.register("Alpha", None, &[]);
1598 palette.register("Beta", None, &[]);
1599 palette.open();
1600 palette.set_query("Alpha");
1601 assert!(palette.selected_action().is_some());
1602
1603 palette.clear_actions();
1604 assert_eq!(palette.action_count(), 0);
1605 assert!(palette.selected_action().is_none());
1606 }
1607
1608 #[test]
1609 fn set_query_refilters() {
1610 let mut palette = CommandPalette::new();
1611 palette.register("Alpha", None, &[]);
1612 palette.register("Beta", None, &[]);
1613 palette.open();
1614 palette.set_query("Alpha");
1615 assert_eq!(palette.query(), "Alpha");
1616 assert_eq!(
1617 palette.selected_action().map(|a| a.title.as_str()),
1618 Some("Alpha")
1619 );
1620 palette.set_query("Beta");
1621 assert_eq!(palette.query(), "Beta");
1622 assert_eq!(
1623 palette.selected_action().map(|a| a.title.as_str()),
1624 Some("Beta")
1625 );
1626 }
1627
1628 #[test]
1629 fn events_ignored_when_hidden() {
1630 let mut palette = CommandPalette::new();
1631 let a = Event::Key(KeyEvent {
1633 code: KeyCode::Char('a'),
1634 modifiers: Modifiers::empty(),
1635 kind: KeyEventKind::Press,
1636 });
1637 assert!(palette.handle_event(&a).is_none());
1638 assert!(!palette.is_visible());
1639 }
1640
1641 #[test]
1646 fn selected_row_has_bold_attribute() {
1647 use ftui_render::grapheme_pool::GraphemePool;
1648
1649 let mut palette = CommandPalette::new();
1650 palette.register("Alpha", None, &[]);
1651 palette.register("Beta", None, &[]);
1652 palette.open();
1653
1654 let area = Rect::from_size(60, 10);
1655 let mut pool = GraphemePool::new();
1656 let mut frame = Frame::new(60, 10, &mut pool);
1657 palette.render(area, &mut frame);
1658
1659 let palette_y = area.y + area.height / 6;
1662 let result_y = palette_y + 2;
1663
1664 let mut found_bold = false;
1666 for x in 0..60u16 {
1667 if let Some(cell) = frame.buffer.get(x, result_y)
1668 && cell.attrs.flags().contains(CellStyleFlags::BOLD)
1669 {
1670 found_bold = true;
1671 break;
1672 }
1673 }
1674 assert!(
1675 found_bold,
1676 "Selected row should have bold attribute for accessibility"
1677 );
1678 }
1679
1680 #[test]
1681 fn selection_marker_visible() {
1682 use ftui_render::grapheme_pool::GraphemePool;
1683
1684 let mut palette = CommandPalette::new();
1685 palette.register("Alpha", None, &[]);
1686 palette.open();
1687
1688 let area = Rect::from_size(60, 10);
1689 let mut pool = GraphemePool::new();
1690 let mut frame = Frame::new(60, 10, &mut pool);
1691 palette.render(area, &mut frame);
1692
1693 let palette_y = area.y + area.height / 6;
1695 let result_y = palette_y + 2;
1696 let mut found_marker = false;
1697 for x in 0..60u16 {
1698 if let Some(cell) = frame.buffer.get(x, result_y)
1699 && cell.content.as_char() == Some('>')
1700 {
1701 found_marker = true;
1702 break;
1703 }
1704 }
1705 assert!(
1706 found_marker,
1707 "Selection marker '>' should be visible (color-independent indicator)"
1708 );
1709 }
1710
1711 #[test]
1712 fn long_title_truncated_with_ellipsis() {
1713 use ftui_render::grapheme_pool::GraphemePool;
1714
1715 let mut palette = CommandPalette::new().with_max_visible(5);
1716 palette.register(
1717 "This Is A Very Long Action Title That Should Be Truncated With Ellipsis",
1718 None,
1719 &[],
1720 );
1721 palette.open();
1722
1723 let area = Rect::from_size(40, 10);
1725 let mut pool = GraphemePool::new();
1726 let mut frame = Frame::new(40, 10, &mut pool);
1727 palette.render(area, &mut frame);
1728
1729 let palette_y = area.y + area.height / 6;
1731 let result_y = palette_y + 2;
1732 let mut found_ellipsis = false;
1733 for x in 0..40u16 {
1734 if let Some(cell) = frame.buffer.get(x, result_y)
1735 && cell.content.as_char() == Some('\u{2026}')
1736 {
1737 found_ellipsis = true;
1738 break;
1739 }
1740 }
1741 assert!(
1742 found_ellipsis,
1743 "Long titles should be truncated with '…' ellipsis"
1744 );
1745 }
1746
1747 #[test]
1748 fn keyboard_only_flow_end_to_end() {
1749 let mut palette = CommandPalette::new();
1750 palette.register("Open File", Some("Open a file from disk"), &["file"]);
1751 palette.register("Save File", Some("Save current file"), &["file"]);
1752 palette.register("Git: Commit", Some("Commit changes"), &["git"]);
1753
1754 let ctrl_p = Event::Key(KeyEvent {
1756 code: KeyCode::Char('p'),
1757 modifiers: Modifiers::CTRL,
1758 kind: KeyEventKind::Press,
1759 });
1760 palette.handle_event(&ctrl_p);
1761 assert!(palette.is_visible());
1762 assert_eq!(palette.result_count(), 3);
1763
1764 for ch in "git".chars() {
1766 let event = Event::Key(KeyEvent {
1767 code: KeyCode::Char(ch),
1768 modifiers: Modifiers::empty(),
1769 kind: KeyEventKind::Press,
1770 });
1771 palette.handle_event(&event);
1772 }
1773 assert!(palette.result_count() >= 1);
1774
1775 let down = Event::Key(KeyEvent {
1777 code: KeyCode::Down,
1778 modifiers: Modifiers::empty(),
1779 kind: KeyEventKind::Press,
1780 });
1781 palette.handle_event(&down);
1782
1783 let up = Event::Key(KeyEvent {
1785 code: KeyCode::Up,
1786 modifiers: Modifiers::empty(),
1787 kind: KeyEventKind::Press,
1788 });
1789 palette.handle_event(&up);
1790 assert_eq!(palette.selected_index(), 0);
1791
1792 let enter = Event::Key(KeyEvent {
1794 code: KeyCode::Enter,
1795 modifiers: Modifiers::empty(),
1796 kind: KeyEventKind::Press,
1797 });
1798 let result = palette.handle_event(&enter);
1799 assert!(matches!(result, Some(PaletteAction::Execute(_))));
1800 assert!(!palette.is_visible());
1801 }
1802
1803 #[test]
1804 fn no_focus_trap_esc_always_dismisses() {
1805 let mut palette = CommandPalette::new();
1806 palette.register("Alpha", None, &[]);
1807 palette.open();
1808
1809 for ch in "xyz".chars() {
1811 let event = Event::Key(KeyEvent {
1812 code: KeyCode::Char(ch),
1813 modifiers: Modifiers::empty(),
1814 kind: KeyEventKind::Press,
1815 });
1816 palette.handle_event(&event);
1817 }
1818 assert_eq!(palette.result_count(), 0); let esc = Event::Key(KeyEvent {
1822 code: KeyCode::Escape,
1823 modifiers: Modifiers::empty(),
1824 kind: KeyEventKind::Press,
1825 });
1826 let result = palette.handle_event(&esc);
1827 assert_eq!(result, Some(PaletteAction::Dismiss));
1828 assert!(!palette.is_visible());
1829 }
1830
1831 #[test]
1832 fn unicode_query_renders_correctly() {
1833 use ftui_render::grapheme_pool::GraphemePool;
1834
1835 let mut palette = CommandPalette::new();
1836 palette.register("Café Menu", None, &["food"]);
1837 palette.open();
1838 palette.set_query("café");
1839
1840 assert_eq!(palette.query(), "café");
1841
1842 let area = Rect::from_size(60, 10);
1843 let mut pool = GraphemePool::new();
1844 let mut frame = Frame::new(60, 10, &mut pool);
1845 palette.render(area, &mut frame);
1846
1847 let palette_y = area.y + area.height / 6;
1850 let input_y = palette_y + 1;
1851
1852 let mut found_query_chars = 0;
1854 for x in 0..60u16 {
1855 if let Some(cell) = frame.buffer.get(x, input_y)
1856 && let Some(ch) = cell.content.as_char()
1857 && "café".contains(ch)
1858 {
1859 found_query_chars += 1;
1860 }
1861 }
1862 assert!(
1864 found_query_chars >= 3,
1865 "Unicode query should render (found {} chars)",
1866 found_query_chars
1867 );
1868 }
1869
1870 #[test]
1871 fn wide_char_query_renders_correctly() {
1872 use ftui_render::grapheme_pool::GraphemePool;
1873
1874 let mut palette = CommandPalette::new();
1875 palette.register("日本語メニュー", None, &["japanese"]);
1876 palette.open();
1877 palette.set_query("日本");
1878
1879 assert_eq!(palette.query(), "日本");
1880
1881 let area = Rect::from_size(60, 10);
1882 let mut pool = GraphemePool::new();
1883 let mut frame = Frame::new(60, 10, &mut pool);
1884 palette.render(area, &mut frame);
1885
1886 let palette_y = area.y + area.height / 6;
1888 let input_y = palette_y + 1;
1889
1890 let mut found_grapheme = false;
1892 for x in 0..60u16 {
1893 if let Some(cell) = frame.buffer.get(x, input_y)
1894 && cell.content.is_grapheme()
1895 {
1896 found_grapheme = true;
1897 break;
1898 }
1899 }
1900 assert!(
1901 found_grapheme,
1902 "Wide character query should render as graphemes"
1903 );
1904 }
1905
1906 #[test]
1907 fn wcag_aa_contrast_ratios() {
1908 let style = PaletteStyle::default();
1911 let bg = PackedRgba::rgb(30, 30, 40);
1912
1913 fn relative_luminance(color: PackedRgba) -> f64 {
1915 fn linearize(c: u8) -> f64 {
1916 let v = c as f64 / 255.0;
1917 if v <= 0.04045 {
1918 v / 12.92
1919 } else {
1920 ((v + 0.055) / 1.055).powf(2.4)
1921 }
1922 }
1923 let r = linearize(color.r());
1924 let g = linearize(color.g());
1925 let b = linearize(color.b());
1926 0.2126 * r + 0.7152 * g + 0.0722 * b
1927 }
1928
1929 fn contrast_ratio(fg: PackedRgba, bg: PackedRgba) -> f64 {
1930 let l1 = relative_luminance(fg);
1931 let l2 = relative_luminance(bg);
1932 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
1933 (lighter + 0.05) / (darker + 0.05)
1934 }
1935
1936 let item_fg = style.item.fg.unwrap();
1938 let item_ratio = contrast_ratio(item_fg, bg);
1939 assert!(
1940 item_ratio >= 4.5,
1941 "Item text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1942 item_ratio
1943 );
1944
1945 let sel_fg = style.item_selected.fg.unwrap();
1947 let sel_bg = style.item_selected.bg.unwrap();
1948 let sel_ratio = contrast_ratio(sel_fg, sel_bg);
1949 assert!(
1950 sel_ratio >= 4.5,
1951 "Selected text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1952 sel_ratio
1953 );
1954
1955 let hl_fg = style.match_highlight.fg.unwrap();
1957 let hl_ratio = contrast_ratio(hl_fg, bg);
1958 assert!(
1959 hl_ratio >= 4.5,
1960 "Highlight text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1961 hl_ratio
1962 );
1963
1964 let desc_fg = style.description.fg.unwrap();
1966 let desc_ratio = contrast_ratio(desc_fg, bg);
1967 assert!(
1968 desc_ratio >= 4.5,
1969 "Description text contrast {:.1}:1 < 4.5:1 (WCAG AA)",
1970 desc_ratio
1971 );
1972 }
1973
1974 #[cfg(feature = "tracing")]
1975 #[test]
1976 fn telemetry_emits_in_order() {
1977 use std::sync::{Arc, Mutex};
1978 use tracing::Subscriber;
1979 use tracing_subscriber::Layer;
1980 use tracing_subscriber::filter::Targets;
1981 use tracing_subscriber::layer::{Context, SubscriberExt};
1982
1983 #[derive(Default)]
1984 struct EventCapture {
1985 events: Arc<Mutex<Vec<String>>>,
1986 }
1987
1988 impl<S> Layer<S> for EventCapture
1989 where
1990 S: Subscriber,
1991 {
1992 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1993 use tracing::field::{Field, Visit};
1994
1995 struct EventVisitor {
1996 name: Option<String>,
1997 }
1998
1999 impl Visit for EventVisitor {
2000 fn record_str(&mut self, field: &Field, value: &str) {
2001 if field.name() == "event" {
2002 self.name = Some(value.to_string());
2003 }
2004 }
2005
2006 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2007 if field.name() == "event" {
2008 self.name = Some(format!("{value:?}"));
2009 }
2010 }
2011 }
2012
2013 let mut visitor = EventVisitor { name: None };
2014 event.record(&mut visitor);
2015 if let Some(name) = visitor.name {
2016 self.events
2017 .lock()
2018 .expect("lock telemetry events")
2019 .push(name);
2020 }
2021 }
2022 }
2023
2024 let events = Arc::new(Mutex::new(Vec::new()));
2025 let capture = EventCapture {
2026 events: Arc::clone(&events),
2027 };
2028
2029 let subscriber = tracing_subscriber::registry()
2030 .with(capture)
2031 .with(Targets::new().with_target(TELEMETRY_TARGET, tracing::Level::INFO));
2032 let _guard = tracing::subscriber::set_default(subscriber);
2033
2034 let mut palette = CommandPalette::new();
2035 palette.register("Alpha", None, &[]);
2036 palette.open();
2037
2038 let a = Event::Key(KeyEvent {
2039 code: KeyCode::Char('a'),
2040 modifiers: Modifiers::empty(),
2041 kind: KeyEventKind::Press,
2042 });
2043 palette.handle_event(&a);
2044
2045 let enter = Event::Key(KeyEvent {
2046 code: KeyCode::Enter,
2047 modifiers: Modifiers::empty(),
2048 kind: KeyEventKind::Press,
2049 });
2050 let _ = palette.handle_event(&enter);
2051
2052 let events = events.lock().expect("lock telemetry events");
2053 let open_idx = events
2054 .iter()
2055 .position(|e| e == "palette_opened")
2056 .expect("palette_opened missing");
2057 let query_idx = events
2058 .iter()
2059 .position(|e| e == "palette_query_updated")
2060 .expect("palette_query_updated missing");
2061 let exec_idx = events
2062 .iter()
2063 .position(|e| e == "palette_action_executed")
2064 .expect("palette_action_executed missing");
2065 let close_idx = events
2066 .iter()
2067 .position(|e| e == "palette_closed")
2068 .expect("palette_closed missing");
2069
2070 assert!(open_idx < query_idx);
2071 assert!(query_idx < exec_idx);
2072 assert!(exec_idx < close_idx);
2073 }
2074}
2075mod property_tests;