Skip to main content

ftui_widgets/command_palette/
mod.rs

1#![forbid(unsafe_code)]
2
3//! Command Palette widget for instant action search.
4//!
5//! This module provides a fuzzy-search command palette with:
6//! - Bayesian match scoring with evidence ledger
7//! - Incremental scoring with query-prefix pruning
8//! - Word-start, prefix, substring, and fuzzy matching
9//! - Conformal rank confidence for tie-break stability
10//! - Match position tracking for highlighting
11//!
12//! # Usage
13//!
14//! ```ignore
15//! let mut palette = CommandPalette::new();
16//! palette.register("Open File", Some("Open a file from disk"), &["file", "open"]);
17//! palette.register("Save File", Some("Save current file"), &["file", "save"]);
18//! palette.open(); // Show the palette
19//!
20//! // In your update loop, handle events:
21//! if let Some(action) = palette.handle_event(event) {
22//!     match action {
23//!         PaletteAction::Execute(id) => { /* run the action */ }
24//!         PaletteAction::Dismiss => { /* palette was closed */ }
25//!     }
26//! }
27//!
28//! // Render as a Widget
29//! palette.render(area, &mut frame);
30//! ```
31//!
32//! # Submodules
33//!
34//! - [`scorer`]: Bayesian fuzzy matcher with explainable scoring
35
36pub 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// ---------------------------------------------------------------------------
122// Action Item
123// ---------------------------------------------------------------------------
124
125/// A single action that can be invoked from the command palette.
126#[derive(Debug, Clone)]
127pub struct ActionItem {
128    /// Unique identifier for this action.
129    pub id: String,
130    /// Display title (searched by the scorer).
131    pub title: String,
132    /// Optional description shown below the title.
133    pub description: Option<String>,
134    /// Tags for boosting search relevance.
135    pub tags: Vec<String>,
136    /// Category for visual grouping (e.g., "Git", "File", "View").
137    pub category: Option<String>,
138}
139
140impl ActionItem {
141    /// Create a new action item.
142    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    /// Set description (builder).
153    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
154        self.description = Some(desc.into());
155        self
156    }
157
158    /// Set tags (builder).
159    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    /// Set category (builder).
165    pub fn with_category(mut self, cat: impl Into<String>) -> Self {
166        self.category = Some(cat.into());
167        self
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Palette Action
173// ---------------------------------------------------------------------------
174
175/// Action returned from event handling.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum PaletteAction {
178    /// User selected an action to execute (contains the action ID).
179    Execute(String),
180    /// User dismissed the palette (Esc).
181    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// ---------------------------------------------------------------------------
219// Palette Style
220// ---------------------------------------------------------------------------
221
222/// Visual styling for the command palette.
223#[derive(Debug, Clone)]
224pub struct PaletteStyle {
225    /// Border style.
226    pub border: Style,
227    /// Query input style.
228    pub input: Style,
229    /// Normal result item style.
230    pub item: Style,
231    /// Selected/highlighted result item style.
232    pub item_selected: Style,
233    /// Match highlight style (for matched characters).
234    pub match_highlight: Style,
235    /// Description text style.
236    pub description: Style,
237    /// Category badge style.
238    pub category: Style,
239    /// Empty state / hint text style.
240    pub hint: Style,
241}
242
243impl Default for PaletteStyle {
244    fn default() -> Self {
245        // Colors chosen for WCAG AA contrast ratios against bg(30,30,40):
246        // - item (190,190,200) on bg(30,30,40) ≈ 9.5:1 (AAA)
247        // - selected (255,255,255) on bg(50,50,75) ≈ 8.8:1 (AAA)
248        // - highlight (255,210,60) on bg(30,30,40) ≈ 11:1 (AAA)
249        // - description (140,140,160) on bg(30,30,40) ≈ 5.2:1 (AA)
250        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// ---------------------------------------------------------------------------
266// Scored Item (internal)
267// ---------------------------------------------------------------------------
268
269/// Internal: a scored result with corpus index.
270#[derive(Debug)]
271struct ScoredItem {
272    /// Index into the actions vec.
273    action_index: usize,
274    /// Match result from scorer.
275    result: MatchResult,
276}
277
278// ---------------------------------------------------------------------------
279// Public Result View
280// ---------------------------------------------------------------------------
281
282/// Read-only view of a scored palette item.
283#[derive(Debug, Clone, Copy)]
284pub struct PaletteMatch<'a> {
285    /// Action metadata.
286    pub action: &'a ActionItem,
287    /// Match result (score, match type, evidence).
288    pub result: &'a MatchResult,
289}
290
291// ---------------------------------------------------------------------------
292// Match Filter
293// ---------------------------------------------------------------------------
294
295/// Optional match-type filter for palette results.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum MatchFilter {
298    /// Show all matches.
299    All,
300    /// Exact match only.
301    Exact,
302    /// Prefix match only.
303    Prefix,
304    /// Word-start match only.
305    WordStart,
306    /// Substring match only.
307    Substring,
308    /// Fuzzy match only.
309    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// ---------------------------------------------------------------------------
327// Command Palette Widget
328// ---------------------------------------------------------------------------
329
330/// Command palette widget for instant action search.
331///
332/// Provides a fuzzy-search overlay with keyboard navigation, match highlighting,
333/// and incremental scoring for responsive keystroke handling.
334///
335/// # Invariants
336///
337/// 1. `selected` is always < `filtered.len()` (or 0 when empty).
338/// 2. Results are sorted by descending score with stable tie-breaking.
339/// 3. Query changes trigger incremental re-scoring (not full rescan)
340///    when the query extends the previous one.
341#[derive(Debug)]
342pub struct CommandPalette {
343    /// Registered actions.
344    actions: Vec<ActionItem>,
345    /// Cached titles for scoring (avoids per-keystroke Vec allocation).
346    titles_cache: Vec<String>,
347    /// Cached lowercased titles for scoring.
348    titles_lower: Vec<String>,
349    /// Cached word-start positions for each lowercased title.
350    titles_word_starts: Vec<Vec<usize>>,
351    /// Current query text.
352    query: String,
353    /// Cursor position in the query (byte offset for simplicity).
354    cursor: usize,
355    /// Currently selected index in filtered results.
356    selected: usize,
357    /// Scroll offset for visible window.
358    scroll_offset: usize,
359    /// Whether the palette is visible.
360    visible: bool,
361    /// Visual styling.
362    style: PaletteStyle,
363    /// Incremental scorer for fast keystroke handling.
364    scorer: IncrementalScorer,
365    /// Current filtered results.
366    filtered: Vec<ScoredItem>,
367    /// Optional match-type filter.
368    match_filter: MatchFilter,
369    /// Generation counter for corpus invalidation.
370    generation: u64,
371    /// Maximum visible results.
372    max_visible: usize,
373    /// Telemetry timing anchor (only when tracing feature is enabled).
374    #[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    /// Create a new empty command palette.
386    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    /// Set the visual style (builder).
409    pub fn with_style(mut self, style: PaletteStyle) -> Self {
410        self.style = style;
411        self
412    }
413
414    /// Set max visible results (builder).
415    pub fn with_max_visible(mut self, n: usize) -> Self {
416        self.max_visible = n;
417        self
418    }
419
420    /// Enable or disable evidence tracking for match results.
421    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    // --- Action Registration ---
431
432    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    /// Register a new action.
476    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    /// Register an action item directly.
496    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    /// Replace all actions with a new list.
504    ///
505    /// This resets caches and refreshes the filtered results.
506    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    /// Clear all registered actions.
517    pub fn clear_actions(&mut self) {
518        self.replace_actions(Vec::new());
519    }
520
521    /// Number of registered actions.
522    pub fn action_count(&self) -> usize {
523        self.actions.len()
524    }
525
526    // --- Visibility ---
527
528    /// Open the palette (show it and focus the query input).
529    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    /// Close the palette.
546    pub fn close(&mut self) {
547        self.close_with_reason(PaletteCloseReason::Programmatic);
548    }
549
550    /// Toggle visibility.
551    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    /// Whether the palette is currently visible.
560    pub fn is_visible(&self) -> bool {
561        self.visible
562    }
563
564    // --- Query Access ---
565
566    /// Current query string.
567    pub fn query(&self) -> &str {
568        &self.query
569    }
570
571    /// Replace the query string and re-run filtering.
572    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    /// Number of filtered results.
582    pub fn result_count(&self) -> usize {
583        self.filtered.len()
584    }
585
586    /// Currently selected index.
587    pub fn selected_index(&self) -> usize {
588        self.selected
589    }
590
591    /// Get the currently selected action, if any.
592    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    /// Read-only access to the selected match (action + result).
599    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    /// Iterate over the current filtered results.
607    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    /// Set a match-type filter and refresh results.
615    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    /// Get scorer statistics for diagnostics.
626    pub fn scorer_stats(&self) -> &IncrementalStats {
627        self.scorer.stats()
628    }
629
630    // --- Event Handling ---
631
632    /// Handle an input event. Returns a `PaletteAction` if the user executed
633    /// or dismissed the palette.
634    ///
635    /// Returns `None` if the event was consumed but no action was triggered,
636    /// or if the palette is not visible.
637    pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
638        if !self.visible {
639            // Check for open shortcut (Ctrl+P)
640            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    /// Handle a key press while the palette is open.
663    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                    // Remove last character
724                    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                    // Ctrl+A: select all (move cursor to start)
735                    if c == 'a' {
736                        self.cursor = 0;
737                    }
738                    // Ctrl+U: clear query
739                    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    /// Re-score the corpus against the current query.
762    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        // Clamp selection.
795        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    /// Adjust scroll_offset to keep selected item visible.
824    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
833// ---------------------------------------------------------------------------
834// Widget Implementation
835// ---------------------------------------------------------------------------
836
837impl 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        // Calculate palette dimensions: centered, ~60% width, height based on results.
844        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        // +3 for: border top, query line, border bottom. +1 if empty hint.
847        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; // ~1/6 from top
852
853        let palette_area = Rect::new(palette_x, palette_y, palette_width, palette_height);
854
855        // Clear the palette area.
856        self.clear_area(palette_area, frame);
857
858        // Draw border.
859        self.draw_border(palette_area, frame);
860
861        // Draw query input line.
862        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        // Draw results list.
871        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        // Position cursor in query input.
882        // Calculate visual cursor position from byte offset by computing display width
883        // of the text up to the cursor position.
884        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    /// Clear the palette area with a background color.
897    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    /// Draw a simple border around the palette.
910    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        // Top border with title.
919        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        // Title "Command Palette" in top border.
940        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        // Side borders.
947        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        // Bottom border.
963        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    /// Draw the query input line with prompt.
988    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        // Draw ">" prompt.
998        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        // Draw query text.
1005        if self.query.is_empty() {
1006            // Placeholder.
1007            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            // Render query text with proper grapheme/width handling.
1022            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    /// Draw the filtered results list.
1053    fn draw_results(&self, area: Rect, frame: &mut Frame) {
1054        if self.filtered.is_empty() {
1055            // Empty state.
1056            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            // Bold attribute for selected row (accessible without color).
1123            let row_attrs = if is_selected {
1124                CellAttrs::new(CellStyleFlags::BOLD, 0)
1125            } else {
1126                CellAttrs::default()
1127            };
1128
1129            // Clear row.
1130            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            // Selection marker (visible without color — structural indicator).
1140            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            // Category badge (if present).
1150            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            // Title with match highlighting and ellipsis truncation.
1178            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) // leave room for '…'
1183            } 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            // Ellipsis for truncated titles.
1236            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}'); // …
1239                    cell.fg = row_fg;
1240                    cell.bg = row_bg;
1241                    cell.attrs = row_attrs;
1242                }
1243                col += 1;
1244            }
1245
1246            // Description (if space allows, with ellipsis truncation).
1247            if let Some(ref desc) = action.description {
1248                col += 2; // gap
1249                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// ---------------------------------------------------------------------------
1304// Tests
1305// ---------------------------------------------------------------------------
1306
1307#[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        // Type "git"
1365        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        // Only "Git: Commit" should match well
1387        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        // Can't go past end
1471        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        // Can't go below 0
1479        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        // With empty query, all items shown — first by score (neutral, so first registered)
1555        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        // Not Ctrl+P, so should be ignored
1632        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    // -----------------------------------------------------------------------
1642    // Accessibility / UX tests (bd-39y4.10)
1643    // -----------------------------------------------------------------------
1644
1645    #[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        // The selected row (first result) should have bold cells.
1660        // Results start at y=2 inside the palette (after border + query).
1661        let palette_y = area.y + area.height / 6;
1662        let result_y = palette_y + 2;
1663
1664        // Check that at least one cell in the first result row is bold
1665        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        // Find the '>' selection marker in the results area
1694        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        // Render in a narrow area
1724        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        // Find the ellipsis character '…' in the results area
1730        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        // Step 1: Open with Ctrl+P
1755        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        // Step 2: Type query to filter
1765        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        // Step 3: Navigate down (in case selected isn't the right one)
1776        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        // Step 4: Navigate back up
1784        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        // Step 5: Execute with Enter
1793        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        // Type some query
1810        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); // no matches
1819
1820        // Esc should still dismiss even with no results
1821        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        // The query "café" should be visible in the input area
1848        // palette_y ≈ 1 (10/6), query line is at palette_y + 1 = 2
1849        let palette_y = area.y + area.height / 6;
1850        let input_y = palette_y + 1;
1851
1852        // Find the query characters in the input row
1853        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        // Should find at least 3 of the 4 characters (c, a, f, é may be grapheme)
1863        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        // Wide characters should be rendered (each takes 2 columns)
1887        let palette_y = area.y + area.height / 6;
1888        let input_y = palette_y + 1;
1889
1890        // Find grapheme cells in the input row
1891        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        // Verify the default style colors meet WCAG AA contrast requirements.
1909        // WCAG AA requires >= 4.5:1 for normal text.
1910        let style = PaletteStyle::default();
1911        let bg = PackedRgba::rgb(30, 30, 40);
1912
1913        // Helper: relative luminance per WCAG 2.0
1914        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        // Item text on palette background
1937        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        // Selected text on selected background
1946        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        // Match highlight on palette background
1956        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        // Description text on palette background
1965        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;