Skip to main content

tui_lipan/widgets/
combo_box.rs

1//! Combo box widget.
2
3use std::sync::Arc;
4
5use crate::callback::{Callback, KeyHandler};
6use crate::core::element::Element;
7use crate::core::event::{KeyCode, KeyEvent};
8use crate::style::{BorderStyle, Length, Padding, ScrollbarConfig, Style, StyleSlot};
9use crate::widgets::{
10    Input, InputEvent, List, ListConfig, ListEvent, ListItem, Popover, PopoverOffset,
11    PopoverPlacement,
12};
13
14/// Commit event emitted by [`ComboBox`].
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ComboBoxCommitEvent {
17    /// Index in the source `items` list when an existing item is committed.
18    pub index: Option<usize>,
19    /// Committed value.
20    pub value: Arc<str>,
21    /// `true` when the committed value comes from free-form query text.
22    pub from_custom_value: bool,
23}
24
25/// A controlled input + dropdown list widget.
26#[derive(Clone)]
27pub struct ComboBox {
28    items: Vec<Arc<str>>,
29    query: Arc<str>,
30    placeholder: Option<Arc<str>>,
31    open: bool,
32    active_index: Option<usize>,
33    selected: Option<usize>,
34    allow_custom_value: bool,
35    width: Length,
36    list_width: Option<Length>,
37    list_height: Length,
38    match_input_width: bool,
39    disabled: bool,
40    placement: PopoverPlacement,
41    offset: PopoverOffset,
42    clamp: bool,
43    auto_flip: bool,
44    input_style: Style,
45    input_hover_style: StyleSlot,
46    input_focus_style: StyleSlot,
47    input_focus_content_style: Style,
48    input_disabled_style: Style,
49    input_hover_border_style: Option<BorderStyle>,
50    input_placeholder_style: Style,
51    input_focus_placeholder_style: Style,
52    input_suffix_open: Arc<str>,
53    input_suffix_closed: Arc<str>,
54    input_suffix_style: Style,
55    input_focus_suffix_style: Style,
56    list_config: ListConfig,
57    empty_text: Option<Arc<str>>,
58    on_query_change: Option<Callback<Arc<str>>>,
59    on_open_change: Option<Callback<bool>>,
60    on_active_index_change: Option<Callback<Option<usize>>>,
61    on_commit: Option<Callback<ComboBoxCommitEvent>>,
62    focusable: bool,
63    tab_stop: bool,
64    on_focus: Option<Callback<()>>,
65    on_blur: Option<Callback<()>>,
66    on_key: Option<KeyHandler>,
67}
68
69impl Default for ComboBox {
70    fn default() -> Self {
71        Self {
72            items: Vec::new(),
73            query: Arc::from(""),
74            placeholder: Some("Type to filter...".into()),
75            open: false,
76            active_index: None,
77            selected: None,
78            allow_custom_value: false,
79            width: Length::Flex(1),
80            list_width: None,
81            list_height: Length::Px(8),
82            match_input_width: false,
83            disabled: false,
84            placement: PopoverPlacement::BelowStart,
85            offset: PopoverOffset::ZERO,
86            clamp: true,
87            auto_flip: true,
88            input_style: Style::default(),
89            input_hover_style: StyleSlot::Inherit,
90            input_focus_style: StyleSlot::Inherit,
91            input_focus_content_style: Style::default(),
92            input_disabled_style: Style::default(),
93            input_hover_border_style: None,
94            input_placeholder_style: Style::default(),
95            input_focus_placeholder_style: Style::default(),
96            input_suffix_open: " ▲".into(),
97            input_suffix_closed: " ▼".into(),
98            input_suffix_style: Style::default(),
99            input_focus_suffix_style: Style::default(),
100            list_config: ListConfig {
101                border: true,
102                border_style: BorderStyle::Plain,
103                padding: Padding::default(),
104                style: Style::default(),
105                selection_style: StyleSlot::Inherit,
106                unfocused_selection_style: StyleSlot::Inherit,
107                selection_full_width: false,
108                selection_symbol: None,
109                selection_symbol_right: None,
110                selection_symbol_style: None,
111                unfocused_selection_symbol_style: None,
112                symbol_column: true,
113                gutter_gap: 0,
114                gutter_for_non_selectable: false,
115                item_horizontal_padding: Padding::default(),
116                header_horizontal_padding: Padding::default(),
117                empty_text_style: Style::default(),
118                item_hover_style: None,
119                scrollbar: false,
120                scrollbar_config: ScrollbarConfig::default(),
121            },
122            empty_text: Some("No matches".into()),
123            on_query_change: None,
124            on_open_change: None,
125            on_active_index_change: None,
126            on_commit: None,
127            focusable: true,
128            tab_stop: true,
129            on_focus: None,
130            on_blur: None,
131            on_key: None,
132        }
133    }
134}
135
136impl ComboBox {
137    /// Create a new combo box.
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Set source items.
143    pub fn items(mut self, items: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
144        self.items = items.into_iter().map(Into::into).collect();
145        self
146    }
147
148    /// Set current query.
149    pub fn query(mut self, query: impl Into<Arc<str>>) -> Self {
150        self.query = query.into();
151        self
152    }
153
154    /// Set placeholder text.
155    pub fn placeholder(mut self, placeholder: impl Into<Arc<str>>) -> Self {
156        self.placeholder = Some(placeholder.into());
157        self
158    }
159
160    /// Set controlled open state.
161    pub fn open(mut self, open: bool) -> Self {
162        self.open = open;
163        self
164    }
165
166    /// Set currently active_index source index.
167    pub fn active_index(mut self, active_index: Option<usize>) -> Self {
168        self.active_index = active_index;
169        self
170    }
171
172    /// Set selected source index.
173    pub fn selected(mut self, selected: Option<usize>) -> Self {
174        self.selected = selected;
175        self
176    }
177
178    /// Allow Enter to commit free-form query text when no item is chosen.
179    pub fn allow_custom_value(mut self, allow_custom_value: bool) -> Self {
180        self.allow_custom_value = allow_custom_value;
181        self
182    }
183
184    /// Set width.
185    pub fn width(mut self, width: Length) -> Self {
186        self.width = width;
187        self
188    }
189
190    /// Set dropdown width override.
191    pub fn list_width(mut self, width: Length) -> Self {
192        self.list_width = Some(width);
193        self
194    }
195
196    /// Set dropdown height.
197    pub fn list_height(mut self, height: Length) -> Self {
198        self.list_height = height;
199        self
200    }
201
202    /// Force dropdown width to exactly match rendered input width.
203    pub fn match_input_width(mut self, match_input_width: bool) -> Self {
204        self.match_input_width = match_input_width;
205        self
206    }
207
208    /// Set disabled state.
209    pub fn disabled(mut self, disabled: bool) -> Self {
210        self.disabled = disabled;
211        self
212    }
213
214    /// Control whether the input is focusable.
215    pub fn focusable(mut self, focusable: bool) -> Self {
216        self.focusable = focusable;
217        self
218    }
219
220    /// Control whether the input participates in tab traversal.
221    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
222        self.tab_stop = tab_stop;
223        self
224    }
225
226    /// Set the callback fired when the input gains focus.
227    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
228        self.on_focus = Some(cb);
229        self
230    }
231
232    /// Set the callback fired when the input loses focus.
233    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
234        self.on_blur = Some(cb);
235        self
236    }
237
238    /// Set focused key handler. Returning `true` consumes the key before built-in navigation.
239    pub fn on_key(mut self, handler: KeyHandler) -> Self {
240        self.on_key = Some(handler);
241        self
242    }
243
244    /// Set popover placement.
245    pub fn placement(mut self, placement: PopoverPlacement) -> Self {
246        self.placement = placement;
247        self
248    }
249
250    /// Set popover offset.
251    pub fn offset(mut self, offset: impl Into<PopoverOffset>) -> Self {
252        self.offset = offset.into();
253        self
254    }
255
256    /// Clamp dropdown to viewport bounds.
257    pub fn clamp(mut self, clamp: bool) -> Self {
258        self.clamp = clamp;
259        self
260    }
261
262    /// Auto-flip dropdown placement when overflowing viewport.
263    pub fn auto_flip(mut self, auto_flip: bool) -> Self {
264        self.auto_flip = auto_flip;
265        self
266    }
267
268    /// Set input base style.
269    pub fn input_style(mut self, style: Style) -> Self {
270        self.input_style = style;
271        self
272    }
273
274    /// Set input hover style.
275    pub fn input_hover_style(mut self, style: Style) -> Self {
276        self.input_hover_style = StyleSlot::Replace(style);
277        self
278    }
279
280    /// Extend the themed input hover style.
281    pub fn extend_input_hover_style(mut self, style: Style) -> Self {
282        self.input_hover_style = StyleSlot::Extend(style);
283        self
284    }
285
286    /// Inherit the themed input hover style.
287    pub fn inherit_input_hover_style(mut self) -> Self {
288        self.input_hover_style = StyleSlot::Inherit;
289        self
290    }
291
292    /// Set input hover style slot directly for composite forwarding.
293    pub fn input_hover_style_slot(mut self, slot: StyleSlot) -> Self {
294        self.input_hover_style = slot;
295        self
296    }
297
298    /// Set input focus chrome style.
299    pub fn input_focus_style(mut self, style: Style) -> Self {
300        self.input_focus_style = StyleSlot::Replace(style);
301        self
302    }
303
304    /// Extend the themed input focus style.
305    pub fn extend_input_focus_style(mut self, style: Style) -> Self {
306        self.input_focus_style = StyleSlot::Extend(style);
307        self
308    }
309
310    /// Inherit the themed input focus style.
311    pub fn inherit_input_focus_style(mut self) -> Self {
312        self.input_focus_style = StyleSlot::Inherit;
313        self
314    }
315
316    /// Set input focus style slot directly for composite forwarding.
317    pub fn input_focus_style_slot(mut self, slot: StyleSlot) -> Self {
318        self.input_focus_style = slot;
319        self
320    }
321
322    /// Set focused input content text style.
323    pub fn input_focus_content_style(mut self, style: Style) -> Self {
324        self.input_focus_content_style = style;
325        self
326    }
327
328    /// Set input disabled style.
329    pub fn input_disabled_style(mut self, style: Style) -> Self {
330        self.input_disabled_style = style;
331        self
332    }
333
334    /// Set input border style while hovered.
335    pub fn input_hover_border_style(mut self, border_style: BorderStyle) -> Self {
336        self.input_hover_border_style = Some(border_style);
337        self
338    }
339
340    /// Set input placeholder style.
341    pub fn input_placeholder_style(mut self, style: Style) -> Self {
342        self.input_placeholder_style = style;
343        self
344    }
345
346    /// Set input placeholder style when focused.
347    pub fn input_focus_placeholder_style(mut self, style: Style) -> Self {
348        self.input_focus_placeholder_style = style;
349        self
350    }
351
352    /// Set suffix displayed when dropdown is open.
353    pub fn input_open_suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
354        self.input_suffix_open = suffix.into();
355        self
356    }
357
358    /// Set suffix displayed when dropdown is closed.
359    pub fn input_closed_suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
360        self.input_suffix_closed = suffix.into();
361        self
362    }
363
364    /// Set input suffix style.
365    pub fn input_suffix_style(mut self, style: Style) -> Self {
366        self.input_suffix_style = style;
367        self
368    }
369
370    /// Set input suffix style when focused.
371    pub fn input_focus_suffix_style(mut self, style: Style) -> Self {
372        self.input_focus_suffix_style = style;
373        self
374    }
375
376    /// Set list config.
377    pub fn list_config(mut self, config: ListConfig) -> Self {
378        self.list_config = config;
379        self
380    }
381
382    /// Set dropdown border visibility.
383    pub fn list_border(mut self, list_border: bool) -> Self {
384        self.list_config.border = list_border;
385        self
386    }
387
388    /// Set dropdown border style.
389    pub fn list_border_style(mut self, border_style: BorderStyle) -> Self {
390        self.list_config.border_style = border_style;
391        self
392    }
393
394    /// Set dropdown padding.
395    pub fn list_padding(mut self, padding: impl Into<Padding>) -> Self {
396        self.list_config.padding = padding.into();
397        self
398    }
399
400    /// Set dropdown base style.
401    pub fn list_style(mut self, style: Style) -> Self {
402        self.list_config.style = style;
403        self
404    }
405
406    /// Set dropdown active_index-item style.
407    pub fn list_selection_style(mut self, style: Style) -> Self {
408        self.list_config.selection_style = StyleSlot::Replace(style);
409        self
410    }
411
412    /// Extend the themed dropdown active_index-item style.
413    pub fn extend_list_selection_style(mut self, style: Style) -> Self {
414        self.list_config.selection_style = StyleSlot::Extend(style);
415        self
416    }
417
418    /// Inherit the themed dropdown active_index-item style.
419    pub fn inherit_list_selection_style(mut self) -> Self {
420        self.list_config.selection_style = StyleSlot::Inherit;
421        self
422    }
423
424    /// Set dropdown active_index-item style slot directly for composite forwarding.
425    pub fn list_selection_style_slot(mut self, slot: StyleSlot) -> Self {
426        self.list_config.selection_style = slot;
427        self
428    }
429
430    /// Set dropdown active_index-item style while the list is not focused.
431    pub fn list_unfocused_selection_style(mut self, style: Style) -> Self {
432        self.list_config.unfocused_selection_style = StyleSlot::Replace(style);
433        self
434    }
435
436    /// Extend the themed dropdown active_index-item style while the list is not focused.
437    pub fn extend_list_unfocused_selection_style(mut self, style: Style) -> Self {
438        self.list_config.unfocused_selection_style = StyleSlot::Extend(style);
439        self
440    }
441
442    /// Inherit the themed dropdown active_index-item style while the list is not focused.
443    pub fn inherit_list_unfocused_selection_style(mut self) -> Self {
444        self.list_config.unfocused_selection_style = StyleSlot::Inherit;
445        self
446    }
447
448    /// Set dropdown unfocused active_index-item style slot directly for composite forwarding.
449    pub fn list_unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
450        self.list_config.unfocused_selection_style = slot;
451        self
452    }
453
454    /// Set dropdown hovered-item style.
455    pub fn list_hover_style(mut self, style: Style) -> Self {
456        self.list_config.item_hover_style = Some(StyleSlot::Replace(style));
457        self
458    }
459
460    /// Extend the themed dropdown hovered-item style.
461    pub fn extend_list_hover_style(mut self, style: Style) -> Self {
462        self.list_config.item_hover_style = Some(StyleSlot::Extend(style));
463        self
464    }
465
466    /// Inherit the themed dropdown hovered-item style.
467    pub fn inherit_list_hover_style(mut self) -> Self {
468        self.list_config.item_hover_style = Some(StyleSlot::Inherit);
469        self
470    }
471
472    /// Set dropdown hovered-item style slot directly for composite forwarding.
473    pub fn list_hover_style_slot(mut self, slot: StyleSlot) -> Self {
474        self.list_config.item_hover_style = Some(slot);
475        self
476    }
477
478    /// Set dropdown active_index-item symbol.
479    pub fn list_selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
480        self.list_config.selection_symbol = symbol.map(Into::into);
481        self
482    }
483
484    /// Set the trailing dropdown selection symbol (right "pill" cap). Pairs with
485    /// [`Self::list_selection_symbol`] and shares the selection symbol style.
486    pub fn list_selection_symbol_right(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
487        self.list_config.selection_symbol_right = symbol.map(Into::into);
488        self
489    }
490
491    /// Set dropdown active_index-item symbol style.
492    pub fn list_selection_symbol_style(mut self, style: Style) -> Self {
493        self.list_config.selection_symbol_style = Some(style);
494        self
495    }
496
497    /// Set dropdown active_index-item symbol style while the list is not focused.
498    pub fn list_unfocused_selection_symbol_style(mut self, style: Style) -> Self {
499        self.list_config.unfocused_selection_symbol_style = Some(style);
500        self
501    }
502
503    /// Enable dropdown scrollbar.
504    pub fn list_scrollbar(mut self, scrollbar: bool) -> Self {
505        self.list_config.scrollbar = scrollbar;
506        self
507    }
508
509    /// Set dropdown scrollbar configuration.
510    pub fn list_scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
511        self.list_config.scrollbar_config = config;
512        self
513    }
514
515    /// Set empty-list text.
516    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
517        self.empty_text = Some(text.into());
518        self
519    }
520
521    /// Set empty-list text style.
522    pub fn empty_text_style(mut self, style: Style) -> Self {
523        self.list_config.empty_text_style = style;
524        self
525    }
526
527    /// Callback fired when query changes.
528    pub fn on_query_change(mut self, cb: Callback<Arc<str>>) -> Self {
529        self.on_query_change = Some(cb);
530        self
531    }
532
533    /// Callback fired when open state should change.
534    pub fn on_open_change(mut self, cb: Callback<bool>) -> Self {
535        self.on_open_change = Some(cb);
536        self
537    }
538
539    /// Callback fired when active_index source index changes.
540    pub fn on_active_index_change(mut self, cb: Callback<Option<usize>>) -> Self {
541        self.on_active_index_change = Some(cb);
542        self
543    }
544
545    /// Callback fired when an item or custom value is committed.
546    pub fn on_commit(mut self, cb: Callback<ComboBoxCommitEvent>) -> Self {
547        self.on_commit = Some(cb);
548        self
549    }
550}
551
552impl From<ComboBox> for Element {
553    fn from(combo: ComboBox) -> Self {
554        let filtered_indices = filtered_item_indices(&combo.items, combo.query.as_ref());
555        let effective_highlight = normalized_active_index(
556            combo.active_index,
557            combo.selected,
558            filtered_indices.as_slice(),
559        );
560        let list_selected = effective_highlight
561            .and_then(|source_index| {
562                filtered_indices
563                    .iter()
564                    .position(|&candidate| candidate == source_index)
565            })
566            .unwrap_or(0);
567
568        let mut input = Input::new(combo.query.clone())
569            .width(combo.width)
570            .style(combo.input_style)
571            .hover_style_slot(combo.input_hover_style)
572            .focus_style_slot(combo.input_focus_style)
573            .focus_content_style(combo.input_focus_content_style)
574            .disabled_style(combo.input_disabled_style)
575            .placeholder_style(combo.input_placeholder_style)
576            .focus_placeholder_style(combo.input_focus_placeholder_style)
577            .suffix(if combo.open {
578                combo.input_suffix_open.clone()
579            } else {
580                combo.input_suffix_closed.clone()
581            })
582            .suffix_style(combo.input_suffix_style)
583            .focus_suffix_style(combo.input_focus_suffix_style)
584            .read_only(combo.disabled)
585            .disabled(combo.disabled)
586            .focusable(combo.focusable)
587            .tab_stop(combo.tab_stop);
588
589        if let Some(cb) = combo.on_focus.clone() {
590            input = input.on_focus(cb);
591        }
592        if let Some(cb) = combo.on_blur.clone() {
593            input = input.on_blur(cb);
594        }
595
596        if let Some(hover_border_style) = combo.input_hover_border_style {
597            input = input.hover_border_style(hover_border_style);
598        }
599
600        if let Some(placeholder) = combo.placeholder.clone() {
601            input = input.placeholder(placeholder);
602        }
603
604        if combo.on_query_change.is_some() || combo.on_open_change.is_some() {
605            let on_query_change = combo.on_query_change.clone();
606            let on_open_change = combo.on_open_change.clone();
607            input = input.on_change(Callback::new(move |event: InputEvent| {
608                if let Some(cb) = on_query_change.as_ref() {
609                    cb.emit(event.value.clone());
610                }
611                if let Some(cb) = on_open_change.as_ref() {
612                    cb.emit(true);
613                }
614            }));
615        }
616
617        {
618            let filtered_indices = filtered_indices.clone();
619            let items = combo.items.clone();
620            let query = combo.query.clone();
621            let selected = combo.selected;
622            let allow_custom_value = combo.allow_custom_value;
623            let open = combo.open;
624            let on_open_change = combo.on_open_change.clone();
625            let on_active_index_change = combo.on_active_index_change.clone();
626            let on_commit = combo.on_commit.clone();
627            let caller_on_key = combo.on_key.clone();
628            input = input.on_key(KeyHandler::new(move |key: KeyEvent| {
629                if caller_on_key
630                    .as_ref()
631                    .is_some_and(|handler| handler.handle(key))
632                {
633                    return true;
634                }
635                match key.code {
636                    KeyCode::Esc if open => {
637                        if let Some(cb) = on_open_change.as_ref() {
638                            cb.emit(false);
639                            true
640                        } else {
641                            false
642                        }
643                    }
644                    KeyCode::Down | KeyCode::Up => {
645                        if filtered_indices.is_empty() {
646                            return true;
647                        }
648
649                        let current_pos = effective_highlight
650                            .and_then(|source_index| {
651                                filtered_indices
652                                    .iter()
653                                    .position(|&candidate| candidate == source_index)
654                            })
655                            .unwrap_or(0);
656                        let next_pos = if key.code == KeyCode::Down {
657                            (current_pos + 1).min(filtered_indices.len().saturating_sub(1))
658                        } else {
659                            current_pos.saturating_sub(1)
660                        };
661                        if let Some(cb) = on_active_index_change.as_ref() {
662                            cb.emit(Some(filtered_indices[next_pos]));
663                        }
664                        if let Some(cb) = on_open_change.as_ref() {
665                            cb.emit(true);
666                        }
667                        true
668                    }
669                    KeyCode::Enter => {
670                        let mut handled = false;
671
672                        let active_index = effective_highlight
673                            .filter(|source_index| filtered_indices.contains(source_index));
674                        let selected =
675                            selected.filter(|source_index| filtered_indices.contains(source_index));
676                        let picked_index = active_index.or(selected);
677
678                        if let Some(cb) = on_commit.as_ref() {
679                            if let Some(index) = picked_index {
680                                cb.emit(ComboBoxCommitEvent {
681                                    index: Some(index),
682                                    value: items[index].clone(),
683                                    from_custom_value: false,
684                                });
685                                handled = true;
686                            } else if allow_custom_value && !query.is_empty() {
687                                cb.emit(ComboBoxCommitEvent {
688                                    index: None,
689                                    value: query.clone(),
690                                    from_custom_value: true,
691                                });
692                                handled = true;
693                            }
694                        }
695
696                        if open && let Some(cb) = on_open_change.as_ref() {
697                            cb.emit(false);
698                            handled = true;
699                        }
700
701                        handled
702                    }
703                    _ => false,
704                }
705            }));
706        }
707
708        let mut list = List::new()
709            .items(
710                filtered_indices
711                    .iter()
712                    .map(|&index| ListItem::new(combo.items[index].clone())),
713            )
714            .selected(list_selected)
715            .border(combo.list_config.border)
716            .border_style(combo.list_config.border_style)
717            .padding(combo.list_config.padding)
718            .style(combo.list_config.style)
719            .selection_symbol(combo.list_config.selection_symbol)
720            .selection_symbol_right(combo.list_config.selection_symbol_right)
721            .selection_symbol_style(
722                combo.list_config.selection_symbol_style.unwrap_or(
723                    combo
724                        .list_config
725                        .selection_style
726                        .explicit_style()
727                        .unwrap_or_default(),
728                ),
729            )
730            .unfocused_selection_symbol_style(
731                combo
732                    .list_config
733                    .unfocused_selection_symbol_style
734                    .or_else(|| combo.list_config.unfocused_selection_style.explicit_style())
735                    .unwrap_or(
736                        combo
737                            .list_config
738                            .selection_style
739                            .explicit_style()
740                            .unwrap_or_default(),
741                    ),
742            )
743            .symbol_column(combo.list_config.symbol_column)
744            .gutter_gap(combo.list_config.gutter_gap)
745            .gutter_for_non_selectable(combo.list_config.gutter_for_non_selectable)
746            .scrollbar(combo.list_config.scrollbar)
747            .scrollbar_config(combo.list_config.scrollbar_config)
748            .width(combo.list_width.unwrap_or(combo.width))
749            .height(combo.list_height)
750            .disabled(combo.disabled)
751            .item_horizontal_padding(combo.list_config.item_horizontal_padding)
752            .header_horizontal_padding(combo.list_config.header_horizontal_padding)
753            .empty_text_style(combo.list_config.empty_text_style);
754        list = list
755            .selection_style_slot(combo.list_config.selection_style)
756            .unfocused_selection_style_slot(combo.list_config.unfocused_selection_style)
757            .item_hover_style_slot(
758                combo
759                    .list_config
760                    .item_hover_style
761                    .unwrap_or(combo.list_config.selection_style),
762            );
763
764        let fit_trigger_width = combo.match_input_width && combo.list_width.is_none();
765
766        if let Some(empty_text) = combo.empty_text {
767            list = list.empty_text(empty_text);
768        }
769
770        if let Some(cb) = combo.on_active_index_change.clone() {
771            let filtered_indices = filtered_indices.clone();
772            list = list.on_select(Callback::new(move |event: ListEvent| {
773                if let Some(source_index) = filtered_indices.get(event.index).copied() {
774                    cb.emit(Some(source_index));
775                }
776            }));
777        }
778
779        if combo.on_commit.is_some() || combo.on_open_change.is_some() {
780            let filtered_indices = filtered_indices.clone();
781            let on_commit = combo.on_commit.clone();
782            let on_open_change = combo.on_open_change.clone();
783            let items = combo.items.clone();
784            list = list.on_activate(Callback::new(move |event: ListEvent| {
785                if let Some(source_index) = filtered_indices.get(event.index).copied()
786                    && let Some(cb) = on_commit.as_ref()
787                {
788                    cb.emit(ComboBoxCommitEvent {
789                        index: Some(source_index),
790                        value: items[source_index].clone(),
791                        from_custom_value: false,
792                    });
793                }
794                if let Some(cb) = on_open_change.as_ref() {
795                    cb.emit(false);
796                }
797            }));
798        }
799
800        let on_close = combo
801            .on_open_change
802            .unwrap_or_else(|| Callback::new(|_| {}));
803
804        Popover::new()
805            .trigger(input)
806            .content(list)
807            .open(combo.open && !combo.disabled)
808            .placement(combo.placement)
809            .offset(combo.offset)
810            .clamp(combo.clamp)
811            .auto_flip(combo.auto_flip)
812            .fit_trigger_width(fit_trigger_width)
813            .min_trigger_width(false)
814            .on_close(Callback::new(move |_| on_close.emit(false)))
815            .into()
816    }
817}
818
819fn filtered_item_indices(items: &[Arc<str>], query: &str) -> Vec<usize> {
820    if query.is_empty() {
821        return (0..items.len()).collect();
822    }
823
824    let query = query.to_ascii_lowercase();
825    items
826        .iter()
827        .enumerate()
828        .filter_map(|(index, item)| {
829            item.to_ascii_lowercase()
830                .contains(query.as_str())
831                .then_some(index)
832        })
833        .collect()
834}
835
836fn normalized_active_index(
837    active_index: Option<usize>,
838    selected: Option<usize>,
839    filtered_indices: &[usize],
840) -> Option<usize> {
841    if filtered_indices.is_empty() {
842        return None;
843    }
844
845    active_index
846        .filter(|index| filtered_indices.contains(index))
847        .or_else(|| selected.filter(|index| filtered_indices.contains(index)))
848        .or_else(|| filtered_indices.first().copied())
849}
850
851#[cfg(test)]
852mod tests {
853    use super::{filtered_item_indices, normalized_active_index};
854
855    #[test]
856    fn filter_returns_all_for_empty_query() {
857        let items = ["Alpha".into(), "Beta".into(), "Gamma".into()];
858        let filtered = filtered_item_indices(&items, "");
859        assert_eq!(filtered, vec![0, 1, 2]);
860    }
861
862    #[test]
863    fn filter_is_case_insensitive() {
864        let items = ["Alpha".into(), "Beta".into(), "Gamma".into()];
865        let filtered = filtered_item_indices(&items, "AL");
866        assert_eq!(filtered, vec![0]);
867    }
868
869    #[test]
870    fn normalized_active_index_prefers_explicit_highlight() {
871        let filtered = vec![2, 4, 6];
872        let resolved = normalized_active_index(Some(4), Some(2), filtered.as_slice());
873        assert_eq!(resolved, Some(4));
874    }
875
876    #[test]
877    fn normalized_active_index_falls_back_to_selected_then_first() {
878        let filtered = vec![1, 3, 5];
879        let resolved = normalized_active_index(Some(2), Some(3), filtered.as_slice());
880        assert_eq!(resolved, Some(3));
881
882        let resolved = normalized_active_index(Some(2), Some(4), filtered.as_slice());
883        assert_eq!(resolved, Some(1));
884    }
885}