Skip to main content

ftui_widgets/command_palette/
mod.rs

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