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