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