Skip to main content

tui_lipan/widgets/search_palette/
mod.rs

1pub(crate) mod component;
2mod matching;
3mod render;
4
5use std::sync::Arc;
6
7use nucleo::pattern::{CaseMatching, Normalization};
8
9use crate::callback::{Callback, KeyHandler};
10use crate::core::element::{Element, Key};
11use crate::style::{
12    BorderStyle, CaretShape, Color, Length, Padding, ScrollbarConfig, Style, StyleSlot,
13};
14use crate::utils::gradient::{ColorGradient, GradientRange};
15use crate::widgets::{ListConfig, ListItem, ListItemGutter, ListItemStatus};
16
17pub(crate) const DEFAULT_SYNC_MATCH_LIMIT: usize = 100;
18
19/// Structured description for a [`SearchItem`].
20///
21/// Supports a left segment (searchable, placed according to [`DescriptionPlacement`])
22/// and a right segment (not searchable, always right-aligned). Both are styled with
23/// `description_style`.
24///
25/// Plain strings convert to a left-only description via [`From`].
26///
27/// # Examples
28///
29/// ```
30/// # use tui_lipan::prelude::ItemDescription;
31/// // Left only (equivalent to a plain string):
32/// let d = ItemDescription::new().left("Code editor");
33///
34/// // Right badge only:
35/// let d = ItemDescription::new().right("Pro");
36///
37/// // Both:
38/// let d = ItemDescription::new().left("Code editor").right("Free");
39/// ```
40#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
41pub struct ItemDescription {
42    /// Left-aligned description text (searchable, respects [`DescriptionPlacement`]).
43    pub left: Option<Arc<str>>,
44    /// Right-aligned text (not searchable, always shown on the right).
45    pub right: Option<Arc<str>>,
46}
47
48impl ItemDescription {
49    /// Create an empty description.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set the left (main) description text.
55    pub fn left(mut self, text: impl Into<Arc<str>>) -> Self {
56        self.left = Some(text.into());
57        self
58    }
59
60    /// Set the right-aligned text (e.g. `"Free"`, `"Pro"`).
61    pub fn right(mut self, text: impl Into<Arc<str>>) -> Self {
62        self.right = Some(text.into());
63        self
64    }
65}
66
67impl From<&str> for ItemDescription {
68    fn from(s: &str) -> Self {
69        Self {
70            left: Some(s.into()),
71            right: None,
72        }
73    }
74}
75
76impl From<String> for ItemDescription {
77    fn from(s: String) -> Self {
78        Self {
79            left: Some(s.into()),
80            right: None,
81        }
82    }
83}
84
85impl From<Arc<str>> for ItemDescription {
86    fn from(s: Arc<str>) -> Self {
87        Self {
88            left: Some(s),
89            right: None,
90        }
91    }
92}
93
94/// A searchable item.
95#[derive(Clone, Debug, PartialEq)]
96pub struct SearchItem<T> {
97    /// Item label (text to search).
98    pub label: Arc<str>,
99    /// Optional structured description.
100    pub description: Option<ItemDescription>,
101    /// Alternative names matched alongside the label. Each alias is scored
102    /// independently and the best alias score competes with the label score
103    /// (the match takes the maximum), so a row can rank well via either its
104    /// canonical label or any alias. Aliases are not displayed.
105    pub aliases: Vec<Arc<str>>,
106    /// Whether the row should render in the list active state.
107    pub active: bool,
108    /// Sort weight applied after matching. Higher values lead; ties keep the
109    /// order matching produced (score order, or source order under
110    /// [`SearchPalette::preserve_item_order`]). Defaults to `0`.
111    pub priority: i32,
112    /// User data.
113    pub value: T,
114}
115
116impl<T> SearchItem<T> {
117    /// Create a new search item.
118    pub fn new(label: impl Into<Arc<str>>, value: T) -> Self {
119        Self {
120            label: label.into(),
121            description: None,
122            aliases: Vec::new(),
123            active: false,
124            priority: 0,
125            value,
126        }
127    }
128
129    /// Set description. Accepts a plain string or an [`ItemDescription`].
130    pub fn description(mut self, description: impl Into<ItemDescription>) -> Self {
131        self.description = Some(description.into());
132        self
133    }
134
135    /// Replace the aliases list.
136    pub fn aliases<I, S>(mut self, aliases: I) -> Self
137    where
138        I: IntoIterator<Item = S>,
139        S: Into<Arc<str>>,
140    {
141        self.aliases = aliases.into_iter().map(Into::into).collect();
142        self
143    }
144
145    /// Append a single alias.
146    pub fn alias(mut self, alias: impl Into<Arc<str>>) -> Self {
147        self.aliases.push(alias.into());
148        self
149    }
150
151    /// Mark this item as active.
152    pub fn active(mut self, active: bool) -> Self {
153        self.active = active;
154        self
155    }
156
157    /// Set the sort weight applied after matching.
158    ///
159    /// Higher values lead. Items sharing a priority keep the order matching
160    /// produced, so a pinned subset (favorites, recents) can float to the top
161    /// of otherwise score-ordered results without disturbing the rest.
162    pub fn priority(mut self, priority: i32) -> Self {
163        self.priority = priority;
164        self
165    }
166}
167
168/// Returns indices into `items` in [`SearchPalette`] fuzzy-match order (best match first).
169///
170/// The query is trimmed; an empty query yields `0..items.len()` in definition order.
171/// Matching uses the same nucleo settings as the widget (smart case matching and normalization).
172pub fn rank_search_palette_indices<T: Clone + PartialEq>(
173    items: &[SearchItem<T>],
174    query: &str,
175) -> Vec<usize> {
176    rank_search_palette_indices_with_score(items, query, |_, _, score| score as f64)
177}
178
179/// Returns indices into `items` in [`SearchPalette`] fuzzy-match order after score adjustment.
180///
181/// The query is trimmed; an empty query yields `0..items.len()` in definition order and does not
182/// call `score_fn`. For non-empty queries, matching uses the same nucleo settings as the widget
183/// (smart case matching and normalization), then calls `score_fn` with the source item index, item,
184/// and raw fuzzy score. Results are ordered by descending adjusted score, with the source item index
185/// as the tie-breaker. `NaN` adjusted scores rank after finite scores, with the source item index
186/// as the tie-breaker between `NaN` results.
187///
188/// Uses [`SearchMatchMode::Fuzzy`]; call [`rank_search_palette_indices_with_mode`] to pick a
189/// different strategy such as [`SearchMatchMode::Hybrid`].
190pub fn rank_search_palette_indices_with_score<T: Clone + PartialEq, F>(
191    items: &[SearchItem<T>],
192    query: &str,
193    score_fn: F,
194) -> Vec<usize>
195where
196    F: FnMut(usize, &SearchItem<T>, u32) -> f64,
197{
198    rank_search_palette_indices_with_mode(items, query, SearchMatchMode::Fuzzy, score_fn)
199}
200
201/// Returns indices into `items` in [`SearchPalette`] match order for a given
202/// [`SearchMatchMode`], after score adjustment.
203///
204/// Identical to [`rank_search_palette_indices_with_score`] but lets the caller pick the matching
205/// strategy (for example [`SearchMatchMode::Hybrid`] to rank exact/prefix/substring matches above
206/// fuzzy ones and reject weak scattered fuzzy hits). The query is trimmed; an empty query yields
207/// `0..items.len()` in definition order and does not call `score_fn`. The `score` passed to
208/// `score_fn` is the mode's raw match score (nucleo's fuzzy score under `Fuzzy`, the composite
209/// tiered score under `Hybrid`), so multiplicative adjustments such as frecency boosts compose the
210/// same way in either mode. Results are ordered by descending adjusted score, with the source item
211/// index as the tie-breaker; `NaN` adjusted scores rank after finite scores.
212pub fn rank_search_palette_indices_with_mode<T: Clone + PartialEq, F>(
213    items: &[SearchItem<T>],
214    query: &str,
215    match_mode: SearchMatchMode,
216    mut score_fn: F,
217) -> Vec<usize>
218where
219    F: FnMut(usize, &SearchItem<T>, u32) -> f64,
220{
221    let query = query.trim();
222    if query.is_empty() {
223        return (0..items.len()).collect();
224    }
225    let entries = matching::build_search_entries(items);
226    let mut results: Vec<_> = matching::match_items(
227        &entries,
228        query,
229        match_mode,
230        CaseMatching::Smart,
231        Normalization::Smart,
232    )
233    .into_iter()
234    .map(|result| {
235        let item_index = result.item_index;
236        let adjusted_score = score_fn(item_index, &items[item_index], result.score);
237        (item_index, adjusted_score)
238    })
239    .collect();
240
241    results.sort_by(|(a_index, a_score), (b_index, b_score)| {
242        match (a_score.is_nan(), b_score.is_nan()) {
243            (true, true) => a_index.cmp(b_index),
244            (true, false) => std::cmp::Ordering::Greater,
245            (false, true) => std::cmp::Ordering::Less,
246            (false, false) => b_score
247                .partial_cmp(a_score)
248                .unwrap_or(std::cmp::Ordering::Equal)
249                .then(a_index.cmp(b_index)),
250        }
251    });
252
253    results
254        .into_iter()
255        .map(|(item_index, _)| item_index)
256        .collect()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{
262        SearchItem, SearchMatchMode, rank_search_palette_indices,
263        rank_search_palette_indices_with_mode, rank_search_palette_indices_with_score,
264    };
265
266    #[test]
267    fn rank_search_palette_indices_uses_identity_scoring() {
268        let items = vec![
269            SearchItem::new("alpha", 0),
270            SearchItem::new("alpine", 1),
271            SearchItem::new("beta", 2),
272        ];
273
274        let ranked = rank_search_palette_indices(&items, "alp");
275        let ranked_with_identity =
276            rank_search_palette_indices_with_score(&items, "alp", |_, _, score| score as f64);
277
278        assert_eq!(ranked_with_identity, ranked);
279    }
280
281    #[test]
282    fn items_arc_and_entries_arc_preserve_shared_slices() {
283        use super::{SearchEntry, SearchPalette};
284        use std::sync::Arc;
285
286        let items: Arc<[SearchItem<usize>]> =
287            Arc::from([SearchItem::new("alpha", 0), SearchItem::new("beta", 1)]);
288        let palette = SearchPalette::new().items_arc(Arc::clone(&items));
289        assert!(Arc::ptr_eq(&palette.props.items, &items));
290        assert!(palette.props.entries.is_empty());
291
292        let entries: Arc<[SearchEntry<usize>]> =
293            Arc::from([SearchEntry::header("Group"), SearchEntry::item("gamma", 2)]);
294        let palette = SearchPalette::new().entries_arc(Arc::clone(&entries));
295        assert!(Arc::ptr_eq(&palette.props.entries, &entries));
296        assert_eq!(palette.props.items.len(), 1);
297        assert_eq!(palette.props.items[0].label.as_ref(), "gamma");
298    }
299
300    #[test]
301    fn rank_search_palette_indices_with_score_can_reorder_matches() {
302        let items = vec![
303            SearchItem::new("alpha", 0),
304            SearchItem::new("alpine", 1),
305            SearchItem::new("beta", 2),
306        ];
307
308        let ranked = rank_search_palette_indices_with_score(&items, "alp", |index, _, score| {
309            if index == 1 {
310                score as f64 + 1_000_000.0
311            } else {
312                score as f64
313            }
314        });
315
316        assert_eq!(ranked, vec![1, 0]);
317    }
318
319    #[test]
320    fn rank_search_palette_indices_with_score_does_not_score_empty_query() {
321        let items = vec![SearchItem::new("alpha", 0), SearchItem::new("alpine", 1)];
322
323        let ranked = rank_search_palette_indices_with_score(&items, "  ", |_, _, _| {
324            panic!("empty queries must not call custom scoring")
325        });
326
327        assert_eq!(ranked, vec![0, 1]);
328    }
329
330    #[test]
331    fn rank_search_palette_indices_with_score_places_nan_after_finite_scores() {
332        let items = vec![
333            SearchItem::new("alpha", 0),
334            SearchItem::new("alpine", 1),
335            SearchItem::new("atlas", 2),
336        ];
337
338        let ranked = rank_search_palette_indices_with_score(&items, "a", |index, _, _| {
339            if index == 1 { 1.0 } else { f64::NAN }
340        });
341
342        assert_eq!(ranked, vec![1, 0, 2]);
343    }
344
345    #[test]
346    fn rank_search_palette_indices_with_mode_applies_hybrid_gating() {
347        let items = vec![
348            SearchItem::new("Enable pane synchronization", 0),
349            SearchItem::new("Layout", 1),
350        ];
351
352        // Hybrid rejects the weak scattered fuzzy match and keeps only the prefix match.
353        let hybrid = rank_search_palette_indices_with_mode(
354            &items,
355            "layo",
356            SearchMatchMode::Hybrid,
357            |_, _, score| score as f64,
358        );
359        assert_eq!(hybrid, vec![1]);
360
361        // Fuzzy (the default) still surfaces the scattered match.
362        let fuzzy =
363            rank_search_palette_indices_with_score(&items, "layo", |_, _, score| score as f64);
364        assert!(fuzzy.contains(&0));
365    }
366}
367
368/// A search event emitted when an item is selected/activated.
369#[derive(Clone, Debug, PartialEq)]
370pub struct SearchEvent<T> {
371    /// Index in the matched list.
372    pub match_index: usize,
373    /// Index in the source item list.
374    pub item_index: usize,
375    /// The matched item.
376    pub item: SearchItem<T>,
377}
378
379/// Match metadata passed to the custom renderer.
380#[derive(Clone, Debug, Default, PartialEq, Eq)]
381pub struct SearchHighlight {
382    /// Matching character indices in the label.
383    pub label_hits: Vec<u32>,
384    /// Matching character indices in [`ItemDescription::left`].
385    pub description_hits: Vec<u32>,
386    /// Matching character indices in [`ItemDescription::right`].
387    pub description_right_hits: Vec<u32>,
388    /// Score reported by the matcher.
389    pub score: u32,
390}
391
392/// Custom item renderer. Return [`Some`] to replace the default render, [`None`] to fall through.
393type SearchRenderer<T> = Arc<dyn Fn(&SearchItem<T>, &SearchHighlight) -> Option<ListItem>>;
394
395/// Custom item gutter renderer. Return [`Some`] to attach a left gutter to the row.
396type SearchGutterRenderer<T> =
397    Arc<dyn Fn(&SearchItem<T>, &SearchHighlight) -> Option<ListItemGutter>>;
398
399/// Custom item status renderer. Return [`Some`] to attach content in the list symbol column.
400type SearchStatusRenderer<T> =
401    Arc<dyn Fn(&SearchItem<T>, &SearchHighlight) -> Option<ListItemStatus>>;
402
403/// Placement for item descriptions.
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
405pub enum DescriptionPlacement {
406    /// Render as `label - description` on the primary line.
407    #[default]
408    Inline,
409    /// Render in the right-aligned slot on the primary line.
410    Right,
411    /// Render as a line above the label.
412    Above,
413    /// Render as a line below the label.
414    Below,
415}
416
417/// Overflow policy for description text.
418#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
419pub enum DescriptionOverflow {
420    /// Keep descriptions on one visual line and truncate with ellipsis.
421    #[default]
422    Truncate,
423    /// Wrap descriptions onto additional lines for above/below placement.
424    /// Wrapping prefers word boundaries.
425    Wrap,
426}
427
428/// Matching strategy used to rank [`SearchPalette`] results.
429#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
430pub enum SearchMatchMode {
431    /// Plain `nucleo` fuzzy matching (default). Label matches outrank
432    /// synonym-only alias hits; description text adds to the score.
433    #[default]
434    Fuzzy,
435    /// Evaluate exact, prefix, word-prefix, substring, and fuzzy matching
436    /// together per field (label, aliases, description, right-hint) and rank
437    /// by match quality in that priority order, so a real substring or
438    /// prefix match always outranks a fuzzy one, and weak scattered fuzzy
439    /// matches are rejected instead of polluting results.
440    /// Contiguous queries may omit separators within a field, so
441    /// `switchmodel` matches `Switch model`.
442    ///
443    /// Each whitespace-separated query term may match a different field, but
444    /// characters within one term never combine across the label, an alias,
445    /// the description, and the right-hand hint. Labels carry the highest
446    /// weight and a match bonus so any label hit outranks a synonym-only
447    /// alias hit; descriptions have a lower weight, and the right-hand hint
448    /// only matches via exact or substring comparison (no fuzzy/prefix
449    /// matching), which suits keybinding-style hints.
450    Hybrid,
451}
452
453/// An entry in the search palette item list.
454///
455/// Use [`SearchEntry::item`] for searchable rows, [`SearchEntry::header`] for
456/// section labels, and [`SearchEntry::spacer`] for blank separator rows.
457/// Headers and spacers are excluded from fuzzy matching but are shown in the
458/// list when results are displayed.
459#[derive(Clone, Debug, PartialEq)]
460pub enum SearchEntry<T> {
461    /// A searchable, selectable item.
462    Item(SearchItem<T>),
463    /// A non-selectable section header.
464    Header(Arc<str>),
465    /// A non-selectable blank spacer row.
466    Spacer,
467}
468
469impl<T> SearchEntry<T> {
470    /// Create a searchable item entry.
471    pub fn item(label: impl Into<Arc<str>>, value: T) -> Self {
472        Self::Item(SearchItem::new(label, value))
473    }
474
475    /// Create a section header entry.
476    pub fn header(label: impl Into<Arc<str>>) -> Self {
477        Self::Header(label.into())
478    }
479
480    /// Create a blank spacer entry.
481    pub fn spacer() -> Self {
482        Self::Spacer
483    }
484
485    /// Set description on an item entry. Accepts a plain string or [`ItemDescription`].
486    /// No-op if called on a header or spacer.
487    pub fn description(self, description: impl Into<ItemDescription>) -> Self {
488        match self {
489            Self::Item(item) => Self::Item(item.description(description)),
490            other => other,
491        }
492    }
493
494    /// Mark an item entry as active. No-op for header and spacer entries.
495    pub fn active(self, active: bool) -> Self {
496        match self {
497            Self::Item(item) => Self::Item(item.active(active)),
498            other => other,
499        }
500    }
501
502    /// Set the sort weight on an item entry. No-op for header and spacer entries.
503    pub fn priority(self, priority: i32) -> Self {
504        match self {
505            Self::Item(item) => Self::Item(item.priority(priority)),
506            other => other,
507        }
508    }
509}
510
511#[derive(Clone)]
512#[allow(missing_docs)]
513pub(crate) struct SearchPaletteProps<T> {
514    items: Arc<[SearchItem<T>]>,
515    entries: Arc<[SearchEntry<T>]>,
516    sync_match_limit: usize,
517    sync_selection: bool,
518    initial_query: Arc<str>,
519    /// Index into [`SearchPaletteProps::items`]. Seeds keyboard selection and
520    /// reseeds it when the prop changes; otherwise navigation remains
521    /// authoritative across result refreshes.
522    initial_selected_item_index: Option<usize>,
523    /// Controlled mode: when `Some`, the query is driven by the caller, not by
524    /// an internal `TextInput`. The `Input` widget is not rendered.
525    query: Option<Arc<str>>,
526    placeholder: Arc<str>,
527    // Layout
528    width: Length,
529    height: Length,
530    max_width: Option<Length>,
531    max_height: Option<Length>,
532    // Input forwarding props
533    input_prefix: Option<Arc<str>>,
534    input_suffix: Option<Arc<str>>,
535    input_border: bool,
536    input_divider: bool,
537    input_divider_style: Style,
538    input_divider_join_frame: bool,
539    input_caret_shape: Option<CaretShape>,
540    input_caret_color: Option<Color>,
541    input_border_style: BorderStyle,
542    input_padding: Padding,
543    input_style: Style,
544    input_hover_style: StyleSlot,
545    input_focus_style: StyleSlot,
546    input_focus_content_style: Style,
547    input_placeholder_style: Style,
548    input_focus_placeholder_style: Style,
549    input_prefix_style: Style,
550    input_focus_prefix_style: Style,
551    input_suffix_style: Style,
552    input_focus_suffix_style: Style,
553    // List forwarding props
554    list_config: ListConfig,
555    list_symbol_column: Option<bool>,
556    list_hover_style: StyleSlot,
557    list_active_style: StyleSlot,
558    list_active_symbol: Option<Arc<str>>,
559    list_active_symbol_style: Option<Style>,
560    list_unselected_symbol: Option<Arc<str>>,
561    list_focusable: bool,
562    input_key: Option<Key>,
563    tab_stop: bool,
564    on_focus: Option<Callback<()>>,
565    on_blur: Option<Callback<()>>,
566    empty_text: Option<Arc<str>>,
567    // Item rendering props
568    item_style: Style,
569    active_item_style: Option<Style>,
570    header_style: Style,
571    description_style: Style,
572    active_description_style: Option<Style>,
573    focused_description_style: Option<Style>,
574    description_placement: DescriptionPlacement,
575    description_separator: Option<Arc<str>>,
576    description_selection: bool,
577    description_overflow: DescriptionOverflow,
578    /// Prefer truncating the primary description before the primary label.
579    primary_truncate_description_first: bool,
580    match_style: Style,
581    show_scores: bool,
582    score_gradient: Option<ColorGradient>,
583    score_range: Option<GradientRange>,
584    /// When `true`, entries (headers/spacers) remain visible during active
585    /// search instead of being hidden. Matched items stay grouped under their
586    /// original headers; empty groups are suppressed.  Navigation follows
587    /// visual (definition) order rather than score order so that arrow keys
588    /// move sequentially through the visible rows.
589    preserve_groups: bool,
590    /// When `true`, keep matched items in source order instead of sorting them by match score.
591    /// Useful when the caller has already established a meaningful result order.
592    preserve_item_order: bool,
593    navigation_wrap: bool,
594    // Matching config
595    match_mode: SearchMatchMode,
596    case_matching: CaseMatching,
597    normalization: Normalization,
598    // Input key interceptor
599    input_key_interceptor: Option<KeyHandler>,
600    // Callbacks
601    on_query_change: Option<Callback<Arc<str>>>,
602    on_select: Option<Callback<SearchEvent<T>>>,
603    on_activate: Option<Callback<SearchEvent<T>>>,
604    render_item: Option<SearchRenderer<T>>,
605    item_status: Option<SearchStatusRenderer<T>>,
606    item_gutter: Option<SearchGutterRenderer<T>>,
607}
608
609impl<T: PartialEq> PartialEq for SearchPaletteProps<T> {
610    fn eq(&self, other: &Self) -> bool {
611        self.items == other.items
612            && self.entries == other.entries
613            && self.sync_match_limit == other.sync_match_limit
614            && self.sync_selection == other.sync_selection
615            && self.initial_query == other.initial_query
616            && self.initial_selected_item_index == other.initial_selected_item_index
617            && self.query == other.query
618            && self.placeholder == other.placeholder
619            && self.width == other.width
620            && self.height == other.height
621            && self.max_width == other.max_width
622            && self.max_height == other.max_height
623            && self.input_prefix == other.input_prefix
624            && self.input_suffix == other.input_suffix
625            && self.input_border == other.input_border
626            && self.input_divider == other.input_divider
627            && self.input_divider_style == other.input_divider_style
628            && self.input_divider_join_frame == other.input_divider_join_frame
629            && self.input_caret_shape == other.input_caret_shape
630            && self.input_caret_color == other.input_caret_color
631            && self.input_border_style == other.input_border_style
632            && self.input_padding == other.input_padding
633            && self.input_style == other.input_style
634            && self.input_hover_style == other.input_hover_style
635            && self.input_focus_style == other.input_focus_style
636            && self.input_placeholder_style == other.input_placeholder_style
637            && self.input_focus_placeholder_style == other.input_focus_placeholder_style
638            && self.input_prefix_style == other.input_prefix_style
639            && self.input_focus_prefix_style == other.input_focus_prefix_style
640            && self.input_suffix_style == other.input_suffix_style
641            && self.input_focus_suffix_style == other.input_focus_suffix_style
642            && self.list_config == other.list_config
643            && self.list_symbol_column == other.list_symbol_column
644            && self.list_hover_style == other.list_hover_style
645            && self.list_active_style == other.list_active_style
646            && self.list_active_symbol == other.list_active_symbol
647            && self.list_active_symbol_style == other.list_active_symbol_style
648            && self.list_unselected_symbol == other.list_unselected_symbol
649            && self.list_focusable == other.list_focusable
650            && self.input_key == other.input_key
651            && self.tab_stop == other.tab_stop
652            && self.on_focus == other.on_focus
653            && self.on_blur == other.on_blur
654            && self.empty_text == other.empty_text
655            && self.item_style == other.item_style
656            && self.active_item_style == other.active_item_style
657            && self.header_style == other.header_style
658            && self.description_style == other.description_style
659            && self.active_description_style == other.active_description_style
660            && self.focused_description_style == other.focused_description_style
661            && self.description_placement == other.description_placement
662            && self.description_separator == other.description_separator
663            && self.description_selection == other.description_selection
664            && self.description_overflow == other.description_overflow
665            && self.primary_truncate_description_first == other.primary_truncate_description_first
666            && self.preserve_groups == other.preserve_groups
667            && self.preserve_item_order == other.preserve_item_order
668            && self.navigation_wrap == other.navigation_wrap
669            && self.match_style == other.match_style
670            && self.show_scores == other.show_scores
671            && self.score_gradient == other.score_gradient
672            && self.score_range == other.score_range
673            && self.match_mode == other.match_mode
674            && self.case_matching == other.case_matching
675            && self.normalization == other.normalization
676            && self.input_key_interceptor.is_some() == other.input_key_interceptor.is_some()
677            && self.on_query_change == other.on_query_change
678            && self.on_select == other.on_select
679            && self.on_activate == other.on_activate
680            && render_item_eq(&self.render_item, &other.render_item)
681            && render_status_eq(&self.item_status, &other.item_status)
682            && render_gutter_eq(&self.item_gutter, &other.item_gutter)
683    }
684}
685
686fn render_item_eq<T>(left: &Option<SearchRenderer<T>>, right: &Option<SearchRenderer<T>>) -> bool {
687    match (left, right) {
688        (Some(left), Some(right)) => Arc::ptr_eq(left, right),
689        (None, None) => true,
690        _ => false,
691    }
692}
693
694fn render_gutter_eq<T>(
695    left: &Option<SearchGutterRenderer<T>>,
696    right: &Option<SearchGutterRenderer<T>>,
697) -> bool {
698    match (left, right) {
699        (Some(left), Some(right)) => Arc::ptr_eq(left, right),
700        (None, None) => true,
701        _ => false,
702    }
703}
704
705fn render_status_eq<T>(
706    left: &Option<SearchStatusRenderer<T>>,
707    right: &Option<SearchStatusRenderer<T>>,
708) -> bool {
709    match (left, right) {
710        (Some(left), Some(right)) => Arc::ptr_eq(left, right),
711        (None, None) => true,
712        _ => false,
713    }
714}
715
716/// A fuzzy search palette powered by `nucleo`.
717///
718/// # Modes
719///
720/// `SearchPalette` supports two query ownership modes:
721///
722/// ## Uncontrolled (default)
723///
724/// The palette renders its own `Input` widget and owns the query state
725/// (including undo/redo history). This is the fast path for the common
726/// "modal search / command palette" use case.
727///
728/// ```no_run
729/// # use tui_lipan::prelude::*;
730/// # use std::sync::Arc;
731/// SearchPalette::<Arc<str>>::new()
732///     .initial_query("src/")
733///     .on_activate(Callback::new(|ev: SearchEvent<Arc<str>>| {
734///         // handle activation
735///     }));
736/// ```
737///
738/// ## Controlled
739///
740/// When [`query`](Self::query) is set the palette operates in controlled mode:
741///
742/// - **No `Input` widget is rendered** - the caller is responsible for
743///   displaying and updating the query string (e.g. inside a `Frame` divider).
744/// - **No `TextInput` or undo history is allocated** - only a single
745///   `Arc<str>` is stored.
746/// - Changes to the `query` prop are picked up automatically via
747///   `on_props_changed`, which refreshes the result list.
748/// - Navigation keys (`↑↓ Enter PgUp PgDn Home End`) are handled by the
749///   component's `on_key` when the results list has focus.
750///
751/// ```no_run
752/// # use tui_lipan::prelude::*;
753/// # use std::sync::Arc;
754/// // The caller owns `query: Arc<str>` and passes it every render.
755/// // SearchPalette only rerenders the results list.
756/// SearchPalette::<Arc<str>>::new()
757///     .query(Arc::from("search text"))
758///     .on_activate(Callback::new(|ev: SearchEvent<Arc<str>>| {
759///         // handle activation
760///     }));
761/// ```
762///
763/// See `examples/search_palette_hub.rs` (Controlled tab) for a full example embedding
764/// the query input inside a `Frame` divider with `join_frame(true)`.
765#[derive(Clone)]
766pub struct SearchPalette<T> {
767    props: SearchPaletteProps<T>,
768}
769
770impl<T: Clone + PartialEq> Default for SearchPalette<T> {
771    fn default() -> Self {
772        Self {
773            props: SearchPaletteProps {
774                items: Arc::from([]),
775                entries: Arc::from([]),
776                sync_match_limit: DEFAULT_SYNC_MATCH_LIMIT,
777                sync_selection: false,
778                initial_query: "".into(),
779                initial_selected_item_index: None,
780                query: None,
781                placeholder: "Search...".into(),
782                width: Length::Flex(1),
783                height: Length::Flex(1),
784                max_width: None,
785                max_height: None,
786                input_prefix: None,
787                input_suffix: None,
788                input_border: false,
789                input_divider: true,
790                input_divider_style: Style::default(),
791                input_divider_join_frame: true,
792                input_caret_shape: None,
793                input_caret_color: None,
794                input_border_style: BorderStyle::Plain,
795                input_padding: Padding {
796                    left: 1,
797                    right: 1,
798                    top: 0,
799                    bottom: 0,
800                },
801                input_style: Style::default(),
802                input_hover_style: StyleSlot::Inherit,
803                input_focus_style: StyleSlot::Inherit,
804                input_focus_content_style: Style::default(),
805                input_placeholder_style: Style::default(),
806                input_focus_placeholder_style: Style::default(),
807                input_prefix_style: Style::default(),
808                input_focus_prefix_style: Style::default(),
809                input_suffix_style: Style::default(),
810                input_focus_suffix_style: Style::default(),
811                list_config: ListConfig {
812                    border: false,
813                    border_style: BorderStyle::Plain,
814                    padding: Padding::default(),
815                    style: Style::default(),
816                    selection_style: StyleSlot::Inherit,
817                    unfocused_selection_style: StyleSlot::Inherit,
818                    selection_full_width: false,
819                    selection_symbol: Some("> ".into()),
820                    selection_symbol_right: None,
821                    selection_symbol_style: None,
822                    unfocused_selection_symbol_style: None,
823                    symbol_column: true,
824                    gutter_gap: 0,
825                    gutter_for_non_selectable: false,
826                    item_horizontal_padding: Padding::default(),
827                    header_horizontal_padding: Padding::default(),
828                    empty_text_style: Style::default(),
829                    item_hover_style: None,
830                    scrollbar: false,
831                    scrollbar_config: ScrollbarConfig::default(),
832                },
833                list_symbol_column: None,
834                list_hover_style: StyleSlot::Inherit,
835                list_active_style: StyleSlot::Inherit,
836                list_active_symbol: None,
837                list_active_symbol_style: None,
838                list_unselected_symbol: None,
839                list_focusable: true,
840                input_key: None,
841                tab_stop: true,
842                on_focus: None,
843                on_blur: None,
844                empty_text: Some("No matches".into()),
845                item_style: Style::default(),
846                active_item_style: None,
847                header_style: Style::default(),
848                description_style: Style::default(),
849                active_description_style: None,
850                focused_description_style: None,
851                description_placement: DescriptionPlacement::Inline,
852                description_separator: None,
853                description_selection: true,
854                description_overflow: DescriptionOverflow::Truncate,
855                primary_truncate_description_first: true,
856                match_style: Style::default(),
857                show_scores: false,
858                score_gradient: None,
859                score_range: None,
860                preserve_groups: false,
861                preserve_item_order: false,
862                navigation_wrap: true,
863                match_mode: SearchMatchMode::default(),
864                case_matching: CaseMatching::Smart,
865                normalization: Normalization::Smart,
866                input_key_interceptor: None,
867                on_query_change: None,
868                on_select: None,
869                on_activate: None,
870                render_item: None,
871                item_status: None,
872                item_gutter: None,
873            },
874        }
875    }
876}
877
878impl<T: Clone + PartialEq> SearchPalette<T> {
879    /// Create a new search palette.
880    pub fn new() -> Self {
881        Self::default()
882    }
883
884    /// Set searchable items (no headers or spacers).
885    pub fn items(mut self, items: impl IntoIterator<Item = SearchItem<T>>) -> Self {
886        self.props.items = items.into_iter().collect::<Vec<_>>().into();
887        self.props.entries = Arc::from([]);
888        self
889    }
890
891    /// Set searchable items from a shared slice.
892    pub fn items_arc(mut self, items: Arc<[SearchItem<T>]>) -> Self {
893        self.props.items = items;
894        self.props.entries = Arc::from([]);
895        self
896    }
897
898    /// Set items with optional headers and spacers.
899    ///
900    /// Use [`SearchEntry::item`] for searchable rows, [`SearchEntry::header`]
901    /// for section labels, and [`SearchEntry::spacer`] for blank separators.
902    /// Headers and spacers are excluded from fuzzy matching. They are shown in
903    /// the results list only while the query is empty; active search renders a
904    /// flat ranked result list for more stable navigation.
905    pub fn entries(mut self, entries: impl IntoIterator<Item = SearchEntry<T>>) -> Self {
906        let entries: Arc<[SearchEntry<T>]> = entries.into_iter().collect::<Vec<_>>().into();
907        self.props.items = entries
908            .iter()
909            .filter_map(|e| {
910                if let SearchEntry::Item(item) = e {
911                    Some(item.clone())
912                } else {
913                    None
914                }
915            })
916            .collect::<Vec<_>>()
917            .into();
918        self.props.entries = entries;
919        self
920    }
921
922    /// Set entries from a shared slice.
923    ///
924    /// Searchable items are derived from [`SearchEntry::Item`] rows in the slice.
925    pub fn entries_arc(mut self, entries: Arc<[SearchEntry<T>]>) -> Self {
926        self.props.items = entries
927            .iter()
928            .filter_map(|e| {
929                if let SearchEntry::Item(item) = e {
930                    Some(item.clone())
931                } else {
932                    None
933                }
934            })
935            .collect::<Vec<_>>()
936            .into();
937        self.props.entries = entries;
938        self
939    }
940
941    /// Set placeholder.
942    pub fn placeholder(mut self, placeholder: impl Into<Arc<str>>) -> Self {
943        self.props.placeholder = placeholder.into();
944        self
945    }
946
947    /// Set the maximum item count that still uses synchronous matching.
948    ///
949    /// Lists at or below this size update immediately on each query change.
950    /// Larger lists keep the previous results visible while a background fuzzy
951    /// search computes the next result set.
952    pub fn sync_match_limit(mut self, limit: usize) -> Self {
953        self.props.sync_match_limit = limit;
954        self
955    }
956
957    /// Keep `on_select` synchronized with the currently visible selection.
958    ///
959    /// When enabled, the palette also emits `on_select` when it establishes an
960    /// initial selection or when query/result changes move the internal
961    /// selection to a different source item. Reranking the same selected source
962    /// item does not emit a duplicate callback.
963    pub fn sync_selection(mut self, sync: bool) -> Self {
964        self.props.sync_selection = sync;
965        self
966    }
967
968    /// Set an initial query to pre-populate the search field.
969    ///
970    /// Only used in uncontrolled mode (when [`query`](Self::query) is not set).
971    pub fn initial_query(mut self, query: impl Into<Arc<str>>) -> Self {
972        self.props.initial_query = query.into();
973        self
974    }
975
976    /// Start with the result row that corresponds to this index in [`items`](Self::items).
977    ///
978    /// The item must be present in the current match list; otherwise selection falls back to the
979    /// first row. Changing this prop later reseeds the selection, but leaving it unchanged does
980    /// not override keyboard or mouse navigation when items are refreshed or reranked. Ignored
981    /// when `None` (default).
982    pub fn initial_selected_item_index(mut self, index: Option<usize>) -> Self {
983        self.props.initial_selected_item_index = index;
984        self
985    }
986
987    /// Control whether Up/Down navigation wraps at list boundaries.
988    ///
989    /// Enabled by default. Disable for boundary rows such as "current position"
990    /// where wrapping from the last row to the first row would jump unexpectedly.
991    pub fn navigation_wrap(mut self, wrap: bool) -> Self {
992        self.props.navigation_wrap = wrap;
993        self
994    }
995
996    /// Drive the search query from outside the widget (controlled mode).
997    ///
998    /// When set, the palette renders **without** an `Input` widget - the caller
999    /// is responsible for displaying and updating the query elsewhere.
1000    /// Changes to this prop are detected via `on_props_changed` and trigger a
1001    /// new async search automatically.
1002    ///
1003    /// Do not combine with [`initial_query`](Self::initial_query); that prop is
1004    /// ignored in controlled mode.
1005    pub fn query(mut self, query: impl Into<Arc<str>>) -> Self {
1006        self.props.query = Some(query.into());
1007        self
1008    }
1009
1010    /// Set requested palette width.
1011    pub fn width(mut self, width: Length) -> Self {
1012        self.props.width = width;
1013        self
1014    }
1015
1016    /// Set requested palette height.
1017    pub fn height(mut self, height: Length) -> Self {
1018        self.props.height = height;
1019        self
1020    }
1021
1022    /// Set maximum palette width constraint.
1023    pub fn max_width(mut self, width: Length) -> Self {
1024        self.props.max_width = Some(width);
1025        self
1026    }
1027
1028    /// Set maximum palette height constraint.
1029    pub fn max_height(mut self, height: Length) -> Self {
1030        self.props.max_height = Some(height);
1031        self
1032    }
1033
1034    /// Set query change callback.
1035    pub fn on_query_change(mut self, cb: Callback<Arc<str>>) -> Self {
1036        self.props.on_query_change = Some(cb);
1037        self
1038    }
1039
1040    /// Set selection callback.
1041    ///
1042    /// By default this fires for explicit keyboard or mouse selection changes.
1043    /// Combine with [`sync_selection`](Self::sync_selection) to also receive the
1044    /// currently visible selection when the palette initializes or when query
1045    /// changes move the internal selection.
1046    pub fn on_select(mut self, cb: Callback<SearchEvent<T>>) -> Self {
1047        self.props.on_select = Some(cb);
1048        self
1049    }
1050
1051    /// Set a pre-insertion key interceptor for the internal `Input` widget.
1052    ///
1053    /// This handler runs **before** text insertion. If it returns `true`, the
1054    /// key is consumed and no character is inserted. Use this to remap keys
1055    /// like spacebar to a different action (e.g. toggle) in the palette.
1056    ///
1057    /// The palette's own navigation keys (arrows, `PageUp`/`PageDown`, `Home`/`End`, and `Enter`
1058    /// to activate) are claimed first and never reach this handler — *while there is a matching
1059    /// row to act on*. With no matches the palette has nothing to navigate to or open, so those
1060    /// keys fall through here instead of being swallowed: that is what lets `Enter` mean "create
1061    /// what was typed" or "start something new" in an empty list.
1062    ///
1063    /// Only effective in uncontrolled mode (when [`query`](Self::query) is not set).
1064    pub fn input_key_interceptor(mut self, handler: KeyHandler) -> Self {
1065        self.props.input_key_interceptor = Some(handler);
1066        self
1067    }
1068
1069    /// Set activation callback.
1070    pub fn on_activate(mut self, cb: Callback<SearchEvent<T>>) -> Self {
1071        self.props.on_activate = Some(cb);
1072        self
1073    }
1074
1075    // -- Input forwarding --
1076
1077    /// Override the prefix shown before the query text (default: `" "`).
1078    pub fn input_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
1079        self.props.input_prefix = Some(prefix.into());
1080        self
1081    }
1082
1083    /// Override the suffix shown after the query text (default: `"{matches}/{total}"`).
1084    pub fn input_suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
1085        self.props.input_suffix = Some(suffix.into());
1086        self
1087    }
1088
1089    /// Set input border.
1090    pub fn input_border(mut self, border: bool) -> Self {
1091        self.props.input_border = border;
1092        self
1093    }
1094
1095    /// Control whether a divider is rendered below the input (uncontrolled mode).
1096    ///
1097    /// Default: `true`.
1098    pub fn input_divider(mut self, divider: bool) -> Self {
1099        self.props.input_divider = divider;
1100        self
1101    }
1102
1103    /// Set the style of the divider below the input (uncontrolled mode).
1104    pub fn input_divider_style(mut self, style: Style) -> Self {
1105        self.props.input_divider_style = style;
1106        self
1107    }
1108
1109    /// Control whether the divider joins the surrounding frame border.
1110    ///
1111    /// Default: `true`.
1112    pub fn input_divider_join_frame(mut self, join: bool) -> Self {
1113        self.props.input_divider_join_frame = join;
1114        self
1115    }
1116
1117    /// Override the active theme's input caret shape (block, bar, or underline).
1118    pub fn input_caret_shape(mut self, shape: CaretShape) -> Self {
1119        self.props.input_caret_shape = Some(shape);
1120        self
1121    }
1122
1123    /// Override the active theme's input caret color (OSC 12 cursor color, terminal support required).
1124    pub fn input_caret_color(mut self, color: Color) -> Self {
1125        self.props.input_caret_color = Some(color);
1126        self
1127    }
1128
1129    /// Set input border style.
1130    pub fn input_border_style(mut self, border_style: BorderStyle) -> Self {
1131        self.props.input_border_style = border_style;
1132        self
1133    }
1134
1135    /// Set input padding.
1136    pub fn input_padding(mut self, padding: impl Into<Padding>) -> Self {
1137        self.props.input_padding = padding.into();
1138        self
1139    }
1140
1141    /// Set input style.
1142    pub fn input_style(mut self, style: Style) -> Self {
1143        self.props.input_style = style;
1144        self
1145    }
1146
1147    /// Set input hover style.
1148    pub fn input_hover_style(mut self, style: Style) -> Self {
1149        self.props.input_hover_style = StyleSlot::Replace(style);
1150        self
1151    }
1152
1153    /// Extend the themed input hover style.
1154    pub fn extend_input_hover_style(mut self, style: Style) -> Self {
1155        self.props.input_hover_style = StyleSlot::Extend(style);
1156        self
1157    }
1158
1159    /// Inherit the themed input hover style.
1160    pub fn inherit_input_hover_style(mut self) -> Self {
1161        self.props.input_hover_style = StyleSlot::Inherit;
1162        self
1163    }
1164
1165    /// Set input hover style slot directly for composite forwarding.
1166    pub fn input_hover_style_slot(mut self, slot: StyleSlot) -> Self {
1167        self.props.input_hover_style = slot;
1168        self
1169    }
1170
1171    /// Set input focus chrome style.
1172    pub fn input_focus_style(mut self, style: Style) -> Self {
1173        self.props.input_focus_style = StyleSlot::Replace(style);
1174        self
1175    }
1176
1177    /// Extend the themed input focus style.
1178    pub fn extend_input_focus_style(mut self, style: Style) -> Self {
1179        self.props.input_focus_style = StyleSlot::Extend(style);
1180        self
1181    }
1182
1183    /// Inherit the themed input focus style.
1184    pub fn inherit_input_focus_style(mut self) -> Self {
1185        self.props.input_focus_style = StyleSlot::Inherit;
1186        self
1187    }
1188
1189    /// Set input focus style slot directly for composite forwarding.
1190    pub fn input_focus_style_slot(mut self, slot: StyleSlot) -> Self {
1191        self.props.input_focus_style = slot;
1192        self
1193    }
1194
1195    /// Set focused input content text style.
1196    pub fn input_focus_content_style(mut self, style: Style) -> Self {
1197        self.props.input_focus_content_style = style;
1198        self
1199    }
1200
1201    /// Set input placeholder style.
1202    pub fn input_placeholder_style(mut self, style: Style) -> Self {
1203        self.props.input_placeholder_style = style;
1204        self
1205    }
1206
1207    /// Set input focus placeholder style.
1208    pub fn input_focus_placeholder_style(mut self, style: Style) -> Self {
1209        self.props.input_focus_placeholder_style = style;
1210        self
1211    }
1212
1213    /// Set input prefix style.
1214    pub fn input_prefix_style(mut self, style: Style) -> Self {
1215        self.props.input_prefix_style = style;
1216        self
1217    }
1218
1219    /// Set input focus prefix style.
1220    pub fn input_focus_prefix_style(mut self, style: Style) -> Self {
1221        self.props.input_focus_prefix_style = style;
1222        self
1223    }
1224
1225    /// Set input suffix style.
1226    pub fn input_suffix_style(mut self, style: Style) -> Self {
1227        self.props.input_suffix_style = style;
1228        self
1229    }
1230
1231    /// Set input focus suffix style.
1232    pub fn input_focus_suffix_style(mut self, style: Style) -> Self {
1233        self.props.input_focus_suffix_style = style;
1234        self
1235    }
1236
1237    // -- List forwarding --
1238
1239    /// Set list config.
1240    pub fn list_config(mut self, config: ListConfig) -> Self {
1241        self.props.list_config = config;
1242        self
1243    }
1244
1245    /// Control whether the internal list reserves and renders its built-in symbol column.
1246    ///
1247    /// This is a convenience override for search/command palettes that use custom
1248    /// row gutters instead of the selected-row marker column. Gutter spacing and
1249    /// non-selectable-row gutter participation remain available through
1250    /// [`Self::list_config`].
1251    pub fn list_symbol_column(mut self, enabled: bool) -> Self {
1252        self.props.list_symbol_column = Some(enabled);
1253        self
1254    }
1255
1256    /// Set list border.
1257    pub fn list_border(mut self, border: bool) -> Self {
1258        self.props.list_config.border = border;
1259        self
1260    }
1261
1262    /// Set list border style.
1263    pub fn list_border_style(mut self, border_style: BorderStyle) -> Self {
1264        self.props.list_config.border_style = border_style;
1265        self
1266    }
1267
1268    /// Set list padding.
1269    pub fn list_padding(mut self, padding: impl Into<Padding>) -> Self {
1270        self.props.list_config.padding = padding.into();
1271        self
1272    }
1273
1274    /// Set list style.
1275    pub fn list_style(mut self, style: Style) -> Self {
1276        self.props.list_config.style = style;
1277        self
1278    }
1279
1280    /// Set list hover style.
1281    pub fn list_hover_style(mut self, style: Style) -> Self {
1282        self.props.list_hover_style = StyleSlot::Replace(style);
1283        self
1284    }
1285
1286    /// Extend the themed list hover style.
1287    pub fn extend_list_hover_style(mut self, style: Style) -> Self {
1288        self.props.list_hover_style = StyleSlot::Extend(style);
1289        self
1290    }
1291
1292    /// Inherit the themed list hover style.
1293    pub fn inherit_list_hover_style(mut self) -> Self {
1294        self.props.list_hover_style = StyleSlot::Inherit;
1295        self
1296    }
1297
1298    /// Set list hover style slot directly for composite forwarding.
1299    pub fn list_hover_style_slot(mut self, slot: StyleSlot) -> Self {
1300        self.props.list_hover_style = slot;
1301        self
1302    }
1303
1304    /// Set list highlight style.
1305    pub fn list_selection_style(mut self, style: Style) -> Self {
1306        self.props.list_config.selection_style = StyleSlot::Replace(style);
1307        self
1308    }
1309
1310    /// Extend the themed list highlight style.
1311    pub fn extend_list_selection_style(mut self, style: Style) -> Self {
1312        self.props.list_config.selection_style = StyleSlot::Extend(style);
1313        self
1314    }
1315
1316    /// Inherit the themed list highlight style.
1317    pub fn inherit_list_selection_style(mut self) -> Self {
1318        self.props.list_config.selection_style = StyleSlot::Inherit;
1319        self
1320    }
1321
1322    /// Set list highlight style slot directly for composite forwarding.
1323    pub fn list_selection_style_slot(mut self, slot: StyleSlot) -> Self {
1324        self.props.list_config.selection_style = slot;
1325        self
1326    }
1327
1328    /// Set list highlight style while the list is not focused.
1329    pub fn list_unfocused_selection_style(mut self, style: Style) -> Self {
1330        self.props.list_config.unfocused_selection_style = StyleSlot::Replace(style);
1331        self
1332    }
1333
1334    /// Extend the themed list highlight style while the list is not focused.
1335    pub fn extend_list_unfocused_selection_style(mut self, style: Style) -> Self {
1336        self.props.list_config.unfocused_selection_style = StyleSlot::Extend(style);
1337        self
1338    }
1339
1340    /// Inherit the themed list highlight style while the list is not focused.
1341    pub fn inherit_list_unfocused_selection_style(mut self) -> Self {
1342        self.props.list_config.unfocused_selection_style = StyleSlot::Inherit;
1343        self
1344    }
1345
1346    /// Set list unfocused highlight style slot directly for composite forwarding.
1347    pub fn list_unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
1348        self.props.list_config.unfocused_selection_style = slot;
1349        self
1350    }
1351
1352    /// Set list highlight symbol.
1353    pub fn list_selection_symbol(mut self, symbol: impl Into<Arc<str>>) -> Self {
1354        self.props.list_config.selection_symbol = Some(symbol.into());
1355        self
1356    }
1357
1358    /// Set the trailing list selection symbol (right "pill" cap). Pairs with
1359    /// [`Self::list_selection_symbol`] and shares the selection symbol style.
1360    pub fn list_selection_symbol_right(mut self, symbol: impl Into<Arc<str>>) -> Self {
1361        self.props.list_config.selection_symbol_right = Some(symbol.into());
1362        self
1363    }
1364
1365    /// Set list highlight symbol style.
1366    pub fn list_selection_symbol_style(mut self, style: Style) -> Self {
1367        self.props.list_config.selection_symbol_style = Some(style);
1368        self
1369    }
1370
1371    /// Set list highlight symbol style while the list is not focused.
1372    pub fn list_unfocused_selection_symbol_style(mut self, style: Style) -> Self {
1373        self.props.list_config.unfocused_selection_symbol_style = Some(style);
1374        self
1375    }
1376
1377    /// Set the symbol shown for non-selected items (indentation alignment).
1378    pub fn list_unselected_symbol(mut self, symbol: impl Into<Arc<str>>) -> Self {
1379        self.props.list_unselected_symbol = Some(symbol.into());
1380        self
1381    }
1382
1383    /// Extend the highlight style to the full width of the list.
1384    pub fn list_selection_full_width(mut self, full_width: bool) -> Self {
1385        self.props.list_config.selection_full_width = full_width;
1386        self
1387    }
1388
1389    /// Set hover style applied to individual list items on mouse hover.
1390    pub fn list_item_hover_style(mut self, style: Style) -> Self {
1391        self.props.list_config.item_hover_style = Some(StyleSlot::Replace(style));
1392        self
1393    }
1394
1395    /// Extend the themed list item-hover style.
1396    pub fn extend_list_item_hover_style(mut self, style: Style) -> Self {
1397        self.props.list_config.item_hover_style = Some(StyleSlot::Extend(style));
1398        self
1399    }
1400
1401    /// Inherit the themed list item-hover style.
1402    pub fn inherit_list_item_hover_style(mut self) -> Self {
1403        self.props.list_config.item_hover_style = Some(StyleSlot::Inherit);
1404        self
1405    }
1406
1407    /// Set list item-hover style slot directly for composite forwarding.
1408    pub fn list_item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
1409        self.props.list_config.item_hover_style = Some(slot);
1410        self
1411    }
1412
1413    /// Set list active item style.
1414    pub fn list_active_style(mut self, style: Style) -> Self {
1415        self.props.list_active_style = StyleSlot::Replace(style);
1416        self
1417    }
1418
1419    /// Extend the themed list active style.
1420    pub fn extend_list_active_style(mut self, style: Style) -> Self {
1421        self.props.list_active_style = StyleSlot::Extend(style);
1422        self
1423    }
1424
1425    /// Inherit the themed list active style.
1426    pub fn inherit_list_active_style(mut self) -> Self {
1427        self.props.list_active_style = StyleSlot::Inherit;
1428        self
1429    }
1430
1431    /// Set list active style slot directly for composite forwarding.
1432    pub fn list_active_style_slot(mut self, slot: StyleSlot) -> Self {
1433        self.props.list_active_style = slot;
1434        self
1435    }
1436
1437    /// Set list active item symbol.
1438    pub fn list_active_symbol(mut self, symbol: impl Into<Arc<str>>) -> Self {
1439        self.props.list_active_symbol = Some(symbol.into());
1440        self
1441    }
1442
1443    /// Set list active item symbol style.
1444    pub fn list_active_symbol_style(mut self, style: Style) -> Self {
1445        self.props.list_active_symbol_style = Some(style);
1446        self
1447    }
1448
1449    /// Set list row padding for normal rows.
1450    ///
1451    /// Only left/right are used by List; top/bottom are ignored.
1452    pub fn list_item_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
1453        self.props.list_config.item_horizontal_padding = padding.into();
1454        self
1455    }
1456
1457    /// Set list row padding for header rows.
1458    ///
1459    /// Only left/right are used by List; top/bottom are ignored.
1460    pub fn list_header_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
1461        self.props.list_config.header_horizontal_padding = padding.into();
1462        self
1463    }
1464
1465    /// Control whether the list can receive keyboard focus.
1466    pub fn list_focusable(mut self, focusable: bool) -> Self {
1467        self.props.list_focusable = focusable;
1468        self
1469    }
1470
1471    /// Set a reconciliation key on the query input.
1472    ///
1473    /// Keying the palette element itself keys the palette's container, which is not focusable, so
1474    /// `Context::request_focus` on that key can only reach the input through the container's
1475    /// first-focusable-descendant fallback - and lands elsewhere the moment the palette gains
1476    /// another focusable widget. This addresses the input directly:
1477    ///
1478    /// ```ignore
1479    /// SearchPalette::<T>::new().input_key("command-palette-query")
1480    /// // ...
1481    /// ctx.request_focus("command-palette-query");
1482    /// ```
1483    ///
1484    /// Only meaningful in uncontrolled mode; a controlled palette renders no input of its own.
1485    pub fn input_key(mut self, key: impl Into<Key>) -> Self {
1486        self.props.input_key = Some(key.into());
1487        self
1488    }
1489
1490    /// Control whether the palette's primary focus target participates in tab navigation.
1491    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
1492        self.props.tab_stop = tab_stop;
1493        self
1494    }
1495
1496    /// Set the callback fired when the palette's primary focus target receives focus.
1497    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
1498        self.props.on_focus = Some(cb);
1499        self
1500    }
1501
1502    /// Set the callback fired when the palette's primary focus target loses focus.
1503    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
1504        self.props.on_blur = Some(cb);
1505        self
1506    }
1507
1508    /// Set list scrollbar.
1509    pub fn list_scrollbar(mut self, scroll: bool) -> Self {
1510        self.props.list_config.scrollbar = scroll;
1511        self
1512    }
1513
1514    /// Set list scrollbar configuration.
1515    pub fn list_scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
1516        self.props.list_config.scrollbar_config = config;
1517        self
1518    }
1519
1520    /// Set empty text.
1521    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
1522        self.props.empty_text = Some(text.into());
1523        self
1524    }
1525
1526    /// Set empty text style.
1527    pub fn empty_text_style(mut self, style: Style) -> Self {
1528        self.props.list_config.empty_text_style = style;
1529        self
1530    }
1531
1532    // -- Item rendering --
1533
1534    /// Set item base style.
1535    pub fn item_style(mut self, style: Style) -> Self {
1536        self.props.item_style = style;
1537        self
1538    }
1539
1540    /// Set the style applied to section header rows (entries created with
1541    /// [`SearchEntry::header`]). Defaults to the inherited/ambient style, which
1542    /// makes headers look like normal items.
1543    pub fn header_style(mut self, style: Style) -> Self {
1544        self.props.header_style = style;
1545        self
1546    }
1547
1548    /// Set description style.
1549    pub fn description_style(mut self, style: Style) -> Self {
1550        self.props.description_style = style;
1551        self
1552    }
1553
1554    /// Set description placement.
1555    pub fn description_placement(mut self, placement: DescriptionPlacement) -> Self {
1556        self.props.description_placement = placement;
1557        self
1558    }
1559
1560    /// Set the separator between label and inline description.
1561    ///
1562    /// Only applies to [`DescriptionPlacement::Inline`]. Defaults to `" - "`.
1563    /// Pass `" "` for a single space gap with no visible separator.
1564    pub fn description_separator(mut self, separator: impl Into<Arc<str>>) -> Self {
1565        self.props.description_separator = Some(separator.into());
1566        self
1567    }
1568
1569    /// Control whether selection highlight applies to description text.
1570    ///
1571    /// For [`DescriptionPlacement::Inline`], description shares the primary line,
1572    /// so this setting has no effect.
1573    pub fn description_selection(mut self, highlight: bool) -> Self {
1574        self.props.description_selection = highlight;
1575        self
1576    }
1577
1578    /// Control whether descriptions wrap or truncate.
1579    ///
1580    /// Wrapping applies to [`DescriptionPlacement::Above`] and
1581    /// [`DescriptionPlacement::Below`].
1582    /// [`DescriptionPlacement::Inline`] and [`DescriptionPlacement::Right`]
1583    /// always truncate to keep a single primary row.
1584    pub fn description_overflow(mut self, overflow: DescriptionOverflow) -> Self {
1585        self.props.description_overflow = overflow;
1586        self
1587    }
1588
1589    /// Control which side of a primary row is truncated first when it has right-aligned content.
1590    ///
1591    /// Defaults to `true`, preserving the usual palette behavior of truncating descriptions before
1592    /// labels. Set to `false` when the right-aligned description is metadata that should remain
1593    /// visible while a long label is shortened.
1594    pub fn primary_truncate_description_first(mut self, truncate: bool) -> Self {
1595        self.props.primary_truncate_description_first = truncate;
1596        self
1597    }
1598
1599    /// Set match highlight style.
1600    pub fn match_style(mut self, style: Style) -> Self {
1601        self.props.match_style = style;
1602        self
1603    }
1604
1605    /// Show a numeric score in the right slot for matched rows.
1606    pub fn show_scores(mut self, show: bool) -> Self {
1607        self.props.show_scores = show;
1608        self
1609    }
1610
1611    /// Set gradient used to color score values.
1612    pub fn score_gradient(mut self, gradient: ColorGradient) -> Self {
1613        self.props.score_gradient = Some(gradient);
1614        self
1615    }
1616
1617    /// Set explicit score range used by score gradient.
1618    pub fn score_range(mut self, min: u64, max: u64) -> Self {
1619        self.props.score_range = Some(GradientRange::new(min, max));
1620        self
1621    }
1622
1623    /// Keep group structure (headers/spacers) visible during active search.
1624    ///
1625    /// By default, groups are only shown while the query is empty and hidden
1626    /// once a search term is entered. When set to `true`, matched items remain
1627    /// grouped under their original category headers, empty groups are
1628    /// suppressed automatically, and arrow-key navigation follows visual
1629    /// (definition) order rather than score order.
1630    pub fn preserve_groups(mut self, preserve: bool) -> Self {
1631        self.props.preserve_groups = preserve;
1632        self
1633    }
1634
1635    /// Keep matched items in their source order instead of reranking them by fuzzy score.
1636    ///
1637    /// Matching still filters the items for the current query, but the surviving rows retain the
1638    /// order supplied to [`Self::items`] or [`Self::entries`]. This is useful when the caller has
1639    /// already searched or ordered the data (for example, scrollback results). The setting applies
1640    /// to both synchronous and asynchronous result updates.
1641    pub fn preserve_item_order(mut self, preserve: bool) -> Self {
1642        self.props.preserve_item_order = preserve;
1643        self
1644    }
1645
1646    /// Set the matching strategy used to rank results.
1647    ///
1648    /// Defaults to [`SearchMatchMode::Fuzzy`]. See [`SearchMatchMode::Hybrid`]
1649    /// for exact/prefix/word-prefix/substring/fuzzy tiered matching.
1650    pub fn match_mode(mut self, mode: SearchMatchMode) -> Self {
1651        self.props.match_mode = mode;
1652        self
1653    }
1654
1655    /// Set case matching configuration.
1656    pub fn case_matching(mut self, case: CaseMatching) -> Self {
1657        self.props.case_matching = case;
1658        self
1659    }
1660
1661    /// Set normalization configuration.
1662    pub fn normalization(mut self, normalization: Normalization) -> Self {
1663        self.props.normalization = normalization;
1664        self
1665    }
1666
1667    /// Override the label style for active items in the default renderer.
1668    ///
1669    /// When set, active items' label spans use this style instead of [`item_style`](Self::item_style).
1670    /// Has no effect when a custom [`render_item`](Self::render_item) renderer is used.
1671    pub fn active_item_style(mut self, style: Style) -> Self {
1672        self.props.active_item_style = Some(style);
1673        self
1674    }
1675
1676    /// Override the description style for active items in the default renderer.
1677    ///
1678    /// When set, active items' description spans use this style instead of
1679    /// [`description_style`](Self::description_style).
1680    /// Has no effect when a custom [`render_item`](Self::render_item) renderer is used.
1681    pub fn active_description_style(mut self, style: Style) -> Self {
1682        self.props.active_description_style = Some(style);
1683        self
1684    }
1685
1686    /// Override the description style for the currently focused (selected) item
1687    /// in the default renderer.
1688    ///
1689    /// When set, the focused item's description spans use this style instead of
1690    /// [`description_style`](Self::description_style). Takes precedence over
1691    /// [`active_description_style`](Self::active_description_style).
1692    /// Has no effect when a custom [`render_item`](Self::render_item) renderer is used.
1693    pub fn focused_description_style(mut self, style: Style) -> Self {
1694        self.props.focused_description_style = Some(style);
1695        self
1696    }
1697
1698    /// Set a custom renderer for matched items.
1699    ///
1700    /// Return [`Some`] to replace the default rendering for that item, or [`None`] to fall
1701    /// through to the built-in default renderer.
1702    pub fn render_item(mut self, renderer: SearchRenderer<T>) -> Self {
1703        self.props.render_item = Some(renderer);
1704        self
1705    }
1706
1707    /// Set a custom per-row status renderer for the list symbol column.
1708    ///
1709    /// Status content replaces the selection or unselected symbol/spaces for
1710    /// that row, while active symbols keep priority.
1711    pub fn item_status(mut self, renderer: SearchStatusRenderer<T>) -> Self {
1712        self.props.item_status = Some(renderer);
1713        self
1714    }
1715
1716    /// Set a custom left-gutter renderer for matched items.
1717    ///
1718    /// This preserves the built-in row rendering and only attaches a fixed-width
1719    /// gutter, which is useful for status widgets such as [`Spinner`](crate::widgets::Spinner).
1720    pub fn item_gutter(mut self, renderer: SearchGutterRenderer<T>) -> Self {
1721        self.props.item_gutter = Some(renderer);
1722        self
1723    }
1724}
1725
1726impl<T: Clone + PartialEq + 'static> From<SearchPalette<T>> for Element {
1727    fn from(palette: SearchPalette<T>) -> Self {
1728        component::element(palette.props)
1729    }
1730}