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