1use 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#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ComboBoxCommitEvent {
17 pub index: Option<usize>,
19 pub value: Arc<str>,
21 pub from_custom_value: bool,
23}
24
25#[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 pub fn new() -> Self {
139 Self::default()
140 }
141
142 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 pub fn query(mut self, query: impl Into<Arc<str>>) -> Self {
150 self.query = query.into();
151 self
152 }
153
154 pub fn placeholder(mut self, placeholder: impl Into<Arc<str>>) -> Self {
156 self.placeholder = Some(placeholder.into());
157 self
158 }
159
160 pub fn open(mut self, open: bool) -> Self {
162 self.open = open;
163 self
164 }
165
166 pub fn active_index(mut self, active_index: Option<usize>) -> Self {
168 self.active_index = active_index;
169 self
170 }
171
172 pub fn selected(mut self, selected: Option<usize>) -> Self {
174 self.selected = selected;
175 self
176 }
177
178 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 pub fn width(mut self, width: Length) -> Self {
186 self.width = width;
187 self
188 }
189
190 pub fn list_width(mut self, width: Length) -> Self {
192 self.list_width = Some(width);
193 self
194 }
195
196 pub fn list_height(mut self, height: Length) -> Self {
198 self.list_height = height;
199 self
200 }
201
202 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 pub fn disabled(mut self, disabled: bool) -> Self {
210 self.disabled = disabled;
211 self
212 }
213
214 pub fn focusable(mut self, focusable: bool) -> Self {
216 self.focusable = focusable;
217 self
218 }
219
220 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
222 self.tab_stop = tab_stop;
223 self
224 }
225
226 pub fn on_focus(mut self, cb: Callback<()>) -> Self {
228 self.on_focus = Some(cb);
229 self
230 }
231
232 pub fn on_blur(mut self, cb: Callback<()>) -> Self {
234 self.on_blur = Some(cb);
235 self
236 }
237
238 pub fn on_key(mut self, handler: KeyHandler) -> Self {
240 self.on_key = Some(handler);
241 self
242 }
243
244 pub fn placement(mut self, placement: PopoverPlacement) -> Self {
246 self.placement = placement;
247 self
248 }
249
250 pub fn offset(mut self, offset: impl Into<PopoverOffset>) -> Self {
252 self.offset = offset.into();
253 self
254 }
255
256 pub fn clamp(mut self, clamp: bool) -> Self {
258 self.clamp = clamp;
259 self
260 }
261
262 pub fn auto_flip(mut self, auto_flip: bool) -> Self {
264 self.auto_flip = auto_flip;
265 self
266 }
267
268 pub fn input_style(mut self, style: Style) -> Self {
270 self.input_style = style;
271 self
272 }
273
274 pub fn input_hover_style(mut self, style: Style) -> Self {
276 self.input_hover_style = StyleSlot::Replace(style);
277 self
278 }
279
280 pub fn extend_input_hover_style(mut self, style: Style) -> Self {
282 self.input_hover_style = StyleSlot::Extend(style);
283 self
284 }
285
286 pub fn inherit_input_hover_style(mut self) -> Self {
288 self.input_hover_style = StyleSlot::Inherit;
289 self
290 }
291
292 pub fn input_hover_style_slot(mut self, slot: StyleSlot) -> Self {
294 self.input_hover_style = slot;
295 self
296 }
297
298 pub fn input_focus_style(mut self, style: Style) -> Self {
300 self.input_focus_style = StyleSlot::Replace(style);
301 self
302 }
303
304 pub fn extend_input_focus_style(mut self, style: Style) -> Self {
306 self.input_focus_style = StyleSlot::Extend(style);
307 self
308 }
309
310 pub fn inherit_input_focus_style(mut self) -> Self {
312 self.input_focus_style = StyleSlot::Inherit;
313 self
314 }
315
316 pub fn input_focus_style_slot(mut self, slot: StyleSlot) -> Self {
318 self.input_focus_style = slot;
319 self
320 }
321
322 pub fn input_focus_content_style(mut self, style: Style) -> Self {
324 self.input_focus_content_style = style;
325 self
326 }
327
328 pub fn input_disabled_style(mut self, style: Style) -> Self {
330 self.input_disabled_style = style;
331 self
332 }
333
334 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 pub fn input_placeholder_style(mut self, style: Style) -> Self {
342 self.input_placeholder_style = style;
343 self
344 }
345
346 pub fn input_focus_placeholder_style(mut self, style: Style) -> Self {
348 self.input_focus_placeholder_style = style;
349 self
350 }
351
352 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 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 pub fn input_suffix_style(mut self, style: Style) -> Self {
366 self.input_suffix_style = style;
367 self
368 }
369
370 pub fn input_focus_suffix_style(mut self, style: Style) -> Self {
372 self.input_focus_suffix_style = style;
373 self
374 }
375
376 pub fn list_config(mut self, config: ListConfig) -> Self {
378 self.list_config = config;
379 self
380 }
381
382 pub fn list_border(mut self, list_border: bool) -> Self {
384 self.list_config.border = list_border;
385 self
386 }
387
388 pub fn list_border_style(mut self, border_style: BorderStyle) -> Self {
390 self.list_config.border_style = border_style;
391 self
392 }
393
394 pub fn list_padding(mut self, padding: impl Into<Padding>) -> Self {
396 self.list_config.padding = padding.into();
397 self
398 }
399
400 pub fn list_style(mut self, style: Style) -> Self {
402 self.list_config.style = style;
403 self
404 }
405
406 pub fn list_selection_style(mut self, style: Style) -> Self {
408 self.list_config.selection_style = StyleSlot::Replace(style);
409 self
410 }
411
412 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 pub fn inherit_list_selection_style(mut self) -> Self {
420 self.list_config.selection_style = StyleSlot::Inherit;
421 self
422 }
423
424 pub fn list_selection_style_slot(mut self, slot: StyleSlot) -> Self {
426 self.list_config.selection_style = slot;
427 self
428 }
429
430 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 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 pub fn inherit_list_unfocused_selection_style(mut self) -> Self {
444 self.list_config.unfocused_selection_style = StyleSlot::Inherit;
445 self
446 }
447
448 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 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 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 pub fn inherit_list_hover_style(mut self) -> Self {
468 self.list_config.item_hover_style = Some(StyleSlot::Inherit);
469 self
470 }
471
472 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 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 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 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 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 pub fn list_scrollbar(mut self, scrollbar: bool) -> Self {
505 self.list_config.scrollbar = scrollbar;
506 self
507 }
508
509 pub fn list_scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
511 self.list_config.scrollbar_config = config;
512 self
513 }
514
515 pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
517 self.empty_text = Some(text.into());
518 self
519 }
520
521 pub fn empty_text_style(mut self, style: Style) -> Self {
523 self.list_config.empty_text_style = style;
524 self
525 }
526
527 pub fn on_query_change(mut self, cb: Callback<Arc<str>>) -> Self {
529 self.on_query_change = Some(cb);
530 self
531 }
532
533 pub fn on_open_change(mut self, cb: Callback<bool>) -> Self {
535 self.on_open_change = Some(cb);
536 self
537 }
538
539 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 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}