Skip to main content

tui_lipan/widgets/
multi_select.rs

1//! Multi-select 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, Span, Style, StyleSlot};
9use crate::widgets::{List, ListConfig, ListEvent, ListItem, ListItemLine};
10
11/// A selectable source item for [`MultiSelect`].
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct MultiSelectItem {
14    /// Primary label.
15    pub label: Arc<str>,
16    /// Optional description.
17    pub description: Option<Arc<str>>,
18}
19
20impl MultiSelectItem {
21    /// Create a new item with label only.
22    pub fn new(label: impl Into<Arc<str>>) -> Self {
23        Self {
24            label: label.into(),
25            description: None,
26        }
27    }
28
29    /// Set optional description.
30    pub fn description(mut self, description: impl Into<Arc<str>>) -> Self {
31        self.description = Some(description.into());
32        self
33    }
34}
35
36impl From<&'static str> for MultiSelectItem {
37    fn from(value: &'static str) -> Self {
38        Self::new(value)
39    }
40}
41
42impl From<String> for MultiSelectItem {
43    fn from(value: String) -> Self {
44        Self::new(value)
45    }
46}
47
48impl From<Arc<str>> for MultiSelectItem {
49    fn from(value: Arc<str>) -> Self {
50        Self::new(value)
51    }
52}
53
54/// Placement for multi-select item descriptions.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
56pub enum MultiSelectDescriptionPlacement {
57    /// Render inline: `label - description`.
58    #[default]
59    Inline,
60    /// Render in right-aligned slot on the primary line.
61    Right,
62    /// Render above the label.
63    Above,
64    /// Render below the label.
65    Below,
66}
67
68/// Overflow policy for multi-select item descriptions.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
70pub enum MultiSelectDescriptionOverflow {
71    /// Keep descriptions on one visual line and truncate with ellipsis.
72    #[default]
73    Truncate,
74    /// Wrap descriptions onto additional lines for above/below placement.
75    Wrap,
76}
77
78/// Toggle event emitted by [`MultiSelect`].
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
80pub struct MultiSelectToggleEvent {
81    /// Source index that changed.
82    pub index: usize,
83    /// Whether the index became selected.
84    pub selected: bool,
85}
86
87/// Selection change event emitted by [`MultiSelect`].
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct MultiSelectChangeEvent {
90    /// Sorted set of selected source indices.
91    pub selected_indices: Vec<usize>,
92}
93
94/// Commit event emitted by [`MultiSelect`].
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct MultiSelectCommitEvent {
97    /// Sorted set of selected source indices.
98    pub selected_indices: Vec<usize>,
99}
100
101/// A controlled list widget for selecting multiple rows.
102///
103/// Checked rows are represented via [`ListItem::active`] internally, so the
104/// symbol rendering priority from [`List`] applies:
105/// `active_symbol` > `selection_symbol` > `unselected_symbol` > auto-spaces.
106#[derive(Clone)]
107pub struct MultiSelect {
108    items: Arc<[MultiSelectItem]>,
109    active_index: usize,
110    selected_indices: Vec<usize>,
111    max_selected: Option<usize>,
112    title: Option<Arc<str>>,
113    title_style: Style,
114    width: Length,
115    height: Length,
116    list_config: ListConfig,
117    /// Symbol shown on checked rows (maps to `List::active_symbol`).
118    active_symbol: Option<Arc<str>>,
119    active_symbol_style: Option<Style>,
120    active_style: StyleSlot,
121    /// Symbol shown on unchecked rows (maps to `List::unselected_symbol`).
122    unselected_symbol: Option<Arc<str>>,
123    description_style: Style,
124    description_placement: MultiSelectDescriptionPlacement,
125    description_overflow: MultiSelectDescriptionOverflow,
126    description_selection: bool,
127    disabled: bool,
128    disabled_style: Style,
129    empty_text: Option<Arc<str>>,
130    focusable: bool,
131    tab_stop: bool,
132    on_focus: Option<Callback<()>>,
133    on_blur: Option<Callback<()>>,
134    on_key: Option<KeyHandler>,
135    on_active_index_change: Option<Callback<usize>>,
136    on_toggle: Option<Callback<MultiSelectToggleEvent>>,
137    on_change: Option<Callback<MultiSelectChangeEvent>>,
138    on_commit: Option<Callback<MultiSelectCommitEvent>>,
139}
140
141impl Default for MultiSelect {
142    fn default() -> Self {
143        Self {
144            items: Arc::from([]),
145            active_index: 0,
146            selected_indices: Vec::new(),
147            max_selected: None,
148            title: None,
149            title_style: Style::default(),
150            width: Length::Flex(1),
151            height: Length::Flex(1),
152            list_config: ListConfig {
153                border: true,
154                border_style: BorderStyle::Plain,
155                padding: Padding::default(),
156                style: Style::default(),
157                selection_style: StyleSlot::Inherit,
158                unfocused_selection_style: StyleSlot::Inherit,
159                selection_full_width: false,
160                selection_symbol: Some("[ ] ".into()),
161                selection_symbol_right: None,
162                selection_symbol_style: None,
163                unfocused_selection_symbol_style: None,
164                symbol_column: true,
165                gutter_gap: 0,
166                gutter_for_non_selectable: false,
167                item_horizontal_padding: Padding::default(),
168                header_horizontal_padding: Padding::default(),
169                empty_text_style: Style::default(),
170                item_hover_style: None,
171                scrollbar: false,
172                scrollbar_config: ScrollbarConfig::default(),
173            },
174            active_symbol: Some("[x] ".into()),
175            active_symbol_style: None,
176            active_style: StyleSlot::Inherit,
177            unselected_symbol: Some("[ ] ".into()),
178            description_style: Style::default(),
179            description_placement: MultiSelectDescriptionPlacement::Inline,
180            description_overflow: MultiSelectDescriptionOverflow::Truncate,
181            description_selection: true,
182            disabled: false,
183            disabled_style: Style::default(),
184            empty_text: None,
185            focusable: true,
186            tab_stop: true,
187            on_focus: None,
188            on_blur: None,
189            on_key: None,
190            on_active_index_change: None,
191            on_toggle: None,
192            on_change: None,
193            on_commit: None,
194        }
195    }
196}
197
198impl MultiSelect {
199    /// Create a new multi-select list.
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Set source items.
205    pub fn items(mut self, items: impl IntoIterator<Item = impl Into<MultiSelectItem>>) -> Self {
206        self.items = items.into_iter().map(Into::into).collect::<Vec<_>>().into();
207        self
208    }
209
210    /// Set source items from a shared slice.
211    pub fn items_arc(mut self, items: Arc<[MultiSelectItem]>) -> Self {
212        self.items = items;
213        self
214    }
215
216    /// Set description style.
217    pub fn description_style(mut self, style: Style) -> Self {
218        self.description_style = style;
219        self
220    }
221
222    /// Set description placement.
223    pub fn description_placement(mut self, placement: MultiSelectDescriptionPlacement) -> Self {
224        self.description_placement = placement;
225        self
226    }
227
228    /// Control whether descriptions wrap or truncate.
229    ///
230    /// Wrapping applies to [`MultiSelectDescriptionPlacement::Above`] and
231    /// [`MultiSelectDescriptionPlacement::Below`].
232    /// [`MultiSelectDescriptionPlacement::Inline`] and
233    /// [`MultiSelectDescriptionPlacement::Right`] always truncate to keep a
234    /// single primary row.
235    pub fn description_overflow(mut self, overflow: MultiSelectDescriptionOverflow) -> Self {
236        self.description_overflow = overflow;
237        self
238    }
239
240    /// Control whether selection highlight applies to description text.
241    ///
242    /// For [`MultiSelectDescriptionPlacement::Inline`], description shares the
243    /// primary line, so this setting has no effect.
244    pub fn description_selection(mut self, highlight: bool) -> Self {
245        self.description_selection = highlight;
246        self
247    }
248
249    /// Set currently active_index source index.
250    pub fn active_index(mut self, active_index: usize) -> Self {
251        self.active_index = active_index;
252        self
253    }
254
255    /// Set selected source indices.
256    pub fn selected_indices(mut self, selected_indices: impl IntoIterator<Item = usize>) -> Self {
257        self.selected_indices = selected_indices.into_iter().collect();
258        self
259    }
260
261    /// Cap how many items can be selected.
262    pub fn max_selected(mut self, max_selected: usize) -> Self {
263        self.max_selected = Some(max_selected);
264        self
265    }
266
267    /// Set width.
268    pub fn width(mut self, width: Length) -> Self {
269        self.width = width;
270        self
271    }
272
273    /// Set list title (visible when border is enabled).
274    pub fn title(mut self, title: impl Into<Arc<str>>) -> Self {
275        self.title = Some(title.into());
276        self
277    }
278
279    /// Set list title style.
280    pub fn title_style(mut self, style: Style) -> Self {
281        self.title_style = style;
282        self
283    }
284
285    /// Set height.
286    pub fn height(mut self, height: Length) -> Self {
287        self.height = height;
288        self
289    }
290
291    /// Set list config.
292    pub fn list_config(mut self, config: ListConfig) -> Self {
293        self.list_config = config;
294        self
295    }
296
297    /// Set border visibility.
298    pub fn border(mut self, border: bool) -> Self {
299        self.list_config.border = border;
300        self
301    }
302
303    /// Set border style.
304    pub fn border_style(mut self, style: BorderStyle) -> Self {
305        self.list_config.border_style = style;
306        self
307    }
308
309    /// Set padding.
310    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
311        self.list_config.padding = padding.into();
312        self
313    }
314
315    /// Set base style.
316    pub fn style(mut self, style: Style) -> Self {
317        self.list_config.style = style;
318        self
319    }
320
321    /// Set active_index-item style.
322    pub fn selection_style(mut self, style: Style) -> Self {
323        self.list_config.selection_style = StyleSlot::Replace(style);
324        self
325    }
326
327    /// Extend the themed active_index-item style.
328    pub fn extend_selection_style(mut self, style: Style) -> Self {
329        self.list_config.selection_style = StyleSlot::Extend(style);
330        self
331    }
332
333    /// Inherit the themed active_index-item style.
334    pub fn inherit_selection_style(mut self) -> Self {
335        self.list_config.selection_style = StyleSlot::Inherit;
336        self
337    }
338
339    /// Set active_index-item style slot directly for composite forwarding.
340    pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
341        self.list_config.selection_style = slot;
342        self
343    }
344
345    /// Set active_index-item style while the list is not focused.
346    pub fn unfocused_selection_style(mut self, style: Style) -> Self {
347        self.list_config.unfocused_selection_style = StyleSlot::Replace(style);
348        self
349    }
350
351    /// Extend the themed active_index-item style while the list is not focused.
352    pub fn extend_unfocused_selection_style(mut self, style: Style) -> Self {
353        self.list_config.unfocused_selection_style = StyleSlot::Extend(style);
354        self
355    }
356
357    /// Inherit the themed active_index-item style while the list is not focused.
358    pub fn inherit_unfocused_selection_style(mut self) -> Self {
359        self.list_config.unfocused_selection_style = StyleSlot::Inherit;
360        self
361    }
362
363    /// Set unfocused active_index-item style slot directly for composite forwarding.
364    pub fn unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
365        self.list_config.unfocused_selection_style = slot;
366        self
367    }
368
369    /// Set whether active_index row style should span full row width.
370    pub fn selection_full_width(mut self, selection_full_width: bool) -> Self {
371        self.list_config.selection_full_width = selection_full_width;
372        self
373    }
374
375    /// Set hovered-item style.
376    pub fn item_hover_style(mut self, style: Style) -> Self {
377        self.list_config.item_hover_style = Some(StyleSlot::Replace(style));
378        self
379    }
380
381    /// Extend the themed hovered-item style.
382    pub fn extend_item_hover_style(mut self, style: Style) -> Self {
383        self.list_config.item_hover_style = Some(StyleSlot::Extend(style));
384        self
385    }
386
387    /// Inherit the themed hovered-item style.
388    pub fn inherit_item_hover_style(mut self) -> Self {
389        self.list_config.item_hover_style = Some(StyleSlot::Inherit);
390        self
391    }
392
393    /// Set hovered-item style slot directly for composite forwarding.
394    pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
395        self.list_config.item_hover_style = Some(slot);
396        self
397    }
398
399    /// Set the symbol shown on the active_index (focused) but unchecked row
400    /// (default: `"[ ] "`).
401    ///
402    /// Defaults to the same bracket as `unselected_symbol` so the visual
403    /// appearance is consistent across all unchecked rows. When the focused
404    /// row is also checked, `active_symbol` takes priority and this is not shown.
405    pub fn selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
406        self.list_config.selection_symbol = symbol.map(Into::into);
407        self
408    }
409
410    /// Set the trailing selection symbol (right "pill" cap). Pairs with
411    /// [`Self::selection_symbol`] and shares [`Self::selection_symbol_style`].
412    pub fn selection_symbol_right(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
413        self.list_config.selection_symbol_right = symbol.map(Into::into);
414        self
415    }
416
417    /// Set style for the highlight symbol.
418    pub fn selection_symbol_style(mut self, style: Style) -> Self {
419        self.list_config.selection_symbol_style = Some(style);
420        self
421    }
422
423    /// Set style for the highlight symbol while the list is not focused.
424    pub fn unfocused_selection_symbol_style(mut self, style: Style) -> Self {
425        self.list_config.unfocused_selection_symbol_style = Some(style);
426        self
427    }
428
429    /// Set the symbol shown on checked rows (default: `"[x] "`).
430    ///
431    /// Maps to [`List::active_symbol`] and takes priority over `selection_symbol`
432    /// even when the row is also the focused cursor row.
433    pub fn active_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
434        self.active_symbol = symbol.map(Into::into);
435        self
436    }
437
438    /// Set style for the checked-row symbol.
439    pub fn active_symbol_style(mut self, style: Style) -> Self {
440        self.active_symbol_style = Some(style);
441        self
442    }
443
444    /// Set style applied to checked rows.
445    pub fn active_style(mut self, style: Style) -> Self {
446        self.active_style = StyleSlot::Replace(style);
447        self
448    }
449
450    /// Extend the themed checked-row style.
451    pub fn extend_active_style(mut self, style: Style) -> Self {
452        self.active_style = StyleSlot::Extend(style);
453        self
454    }
455
456    /// Inherit the themed checked-row style.
457    pub fn inherit_active_style(mut self) -> Self {
458        self.active_style = StyleSlot::Inherit;
459        self
460    }
461
462    /// Set checked-row style slot directly for composite forwarding.
463    pub fn active_style_slot(mut self, slot: StyleSlot) -> Self {
464        self.active_style = slot;
465        self
466    }
467
468    /// Set the symbol shown on unchecked rows (default: `"[ ] "`).
469    ///
470    /// Maps to [`List::unselected_symbol`]. Set to `None` to remove the
471    /// prefix column entirely (items will be left-aligned without indentation).
472    pub fn unselected_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
473        self.unselected_symbol = symbol.map(Into::into);
474        self
475    }
476
477    /// Set disabled state.
478    pub fn disabled(mut self, disabled: bool) -> Self {
479        self.disabled = disabled;
480        self
481    }
482
483    /// Set disabled style.
484    pub fn disabled_style(mut self, style: Style) -> Self {
485        self.disabled_style = style;
486        self
487    }
488
489    /// Control whether the list is focusable.
490    pub fn focusable(mut self, focusable: bool) -> Self {
491        self.focusable = focusable;
492        self
493    }
494
495    /// Control whether the list participates in tab traversal.
496    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
497        self.tab_stop = tab_stop;
498        self
499    }
500
501    /// Set the callback fired when the list gains focus.
502    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
503        self.on_focus = Some(cb);
504        self
505    }
506
507    /// Set the callback fired when the list loses focus.
508    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
509        self.on_blur = Some(cb);
510        self
511    }
512
513    /// Set focused key handler. Returning `true` consumes the key before built-in toggle.
514    pub fn on_key(mut self, handler: KeyHandler) -> Self {
515        self.on_key = Some(handler);
516        self
517    }
518
519    /// Enable scrollbar.
520    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
521        self.list_config.scrollbar = scrollbar;
522        self
523    }
524
525    /// Set scrollbar configuration.
526    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
527        self.list_config.scrollbar_config = config;
528        self
529    }
530
531    /// Set empty-list text.
532    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
533        self.empty_text = Some(text.into());
534        self
535    }
536
537    /// Set empty-list text style.
538    pub fn empty_text_style(mut self, style: Style) -> Self {
539        self.list_config.empty_text_style = style;
540        self
541    }
542
543    /// Callback fired when active_index row changes.
544    pub fn on_active_index_change(mut self, cb: Callback<usize>) -> Self {
545        self.on_active_index_change = Some(cb);
546        self
547    }
548
549    /// Callback fired when the current row toggles selected/unselected.
550    pub fn on_toggle(mut self, cb: Callback<MultiSelectToggleEvent>) -> Self {
551        self.on_toggle = Some(cb);
552        self
553    }
554
555    /// Callback fired with the full selected set after toggles.
556    pub fn on_change(mut self, cb: Callback<MultiSelectChangeEvent>) -> Self {
557        self.on_change = Some(cb);
558        self
559    }
560
561    /// Callback fired on Enter with the selected set.
562    pub fn on_commit(mut self, cb: Callback<MultiSelectCommitEvent>) -> Self {
563        self.on_commit = Some(cb);
564        self
565    }
566}
567
568impl From<MultiSelect> for Element {
569    fn from(multi: MultiSelect) -> Self {
570        let selected_indices =
571            normalize_selected_indices(multi.selected_indices, multi.items.len());
572        let active_index = normalized_active_index(multi.active_index, multi.items.len());
573
574        let list_items = multi
575            .items
576            .iter()
577            .enumerate()
578            .map(|(index, item)| {
579                let is_checked = selected_indices.binary_search(&index).is_ok();
580                multi_select_list_item(
581                    item,
582                    is_checked,
583                    multi.description_style,
584                    multi.description_placement,
585                    multi.description_overflow,
586                    multi.description_selection,
587                )
588            })
589            .collect::<Vec<_>>();
590
591        let mut list = List::new()
592            .items(list_items)
593            .selected(active_index)
594            .activate_on_click(false)
595            .border(multi.list_config.border)
596            .border_style(multi.list_config.border_style)
597            .padding(multi.list_config.padding)
598            .style(multi.list_config.style)
599            .selection_full_width(multi.list_config.selection_full_width)
600            .selection_symbol(multi.list_config.selection_symbol)
601            .selection_symbol_right(multi.list_config.selection_symbol_right)
602            .symbol_column(multi.list_config.symbol_column)
603            .gutter_gap(multi.list_config.gutter_gap)
604            .gutter_for_non_selectable(multi.list_config.gutter_for_non_selectable)
605            .active_symbol(multi.active_symbol)
606            .active_style_slot(multi.active_style)
607            .unselected_symbol(multi.unselected_symbol)
608            .scrollbar(multi.list_config.scrollbar)
609            .scrollbar_config(multi.list_config.scrollbar_config)
610            .title_style(multi.title_style)
611            .width(multi.width)
612            .height(multi.height)
613            .disabled(multi.disabled)
614            .disabled_style(multi.disabled_style)
615            .focusable(multi.focusable)
616            .tab_stop(multi.tab_stop)
617            .item_horizontal_padding(multi.list_config.item_horizontal_padding)
618            .header_horizontal_padding(multi.list_config.header_horizontal_padding)
619            .empty_text_style(multi.list_config.empty_text_style);
620        list = list
621            .selection_style_slot(multi.list_config.selection_style)
622            .unfocused_selection_style_slot(multi.list_config.unfocused_selection_style)
623            .item_hover_style_slot(
624                multi
625                    .list_config
626                    .item_hover_style
627                    .unwrap_or(multi.list_config.selection_style),
628            );
629
630        if let Some(cb) = multi.on_focus.clone() {
631            list = list.on_focus(cb);
632        }
633        if let Some(cb) = multi.on_blur.clone() {
634            list = list.on_blur(cb);
635        }
636
637        if let Some(style) = multi.list_config.selection_symbol_style {
638            list = list.selection_symbol_style(style);
639        }
640        if let Some(style) = multi.list_config.unfocused_selection_symbol_style {
641            list = list.unfocused_selection_symbol_style(style);
642        }
643        if let Some(style) = multi.active_symbol_style {
644            list = list.active_symbol_style(style);
645        }
646        if let Some(title) = multi.title {
647            list = list.title(title);
648        }
649        if let Some(empty_text) = multi.empty_text {
650            list = list.empty_text(empty_text);
651        }
652
653        if let Some(cb) = multi.on_active_index_change.clone() {
654            list = list.on_select(Callback::new(move |event: ListEvent| cb.emit(event.index)));
655        }
656
657        if let Some(cb) = multi.on_commit {
658            let selected_indices = selected_indices.clone();
659            list = list.on_activate(Callback::new(move |_event: ListEvent| {
660                cb.emit(MultiSelectCommitEvent {
661                    selected_indices: selected_indices.clone(),
662                });
663            }));
664        }
665
666        if !multi.disabled && (multi.on_toggle.is_some() || multi.on_change.is_some()) {
667            let selected_indices = selected_indices.clone();
668            let max_selected = multi.max_selected;
669            let on_toggle = multi.on_toggle;
670            let on_change = multi.on_change;
671            let selected_indices_click = selected_indices.clone();
672            let on_toggle_click = on_toggle.clone();
673            let on_change_click = on_change.clone();
674
675            list = list.on_item_click(Callback::new(move |event: ListEvent| {
676                let (next_selected, toggled_to_selected) = match toggle_selection(
677                    selected_indices_click.as_slice(),
678                    event.index,
679                    max_selected,
680                ) {
681                    Some(result) => result,
682                    None => return,
683                };
684
685                if let Some(cb) = on_toggle_click.as_ref() {
686                    cb.emit(MultiSelectToggleEvent {
687                        index: event.index,
688                        selected: toggled_to_selected,
689                    });
690                }
691                if let Some(cb) = on_change_click.as_ref() {
692                    cb.emit(MultiSelectChangeEvent {
693                        selected_indices: next_selected,
694                    });
695                }
696            }));
697
698            let caller_on_key = multi.on_key.clone();
699            list = list.on_key(KeyHandler::new(move |key: KeyEvent| {
700                if caller_on_key
701                    .as_ref()
702                    .is_some_and(|handler| handler.handle(key))
703                {
704                    return true;
705                }
706                if key.code != KeyCode::Char(' ') {
707                    return false;
708                }
709
710                let (next_selected, toggled_to_selected) =
711                    match toggle_selection(selected_indices.as_slice(), active_index, max_selected)
712                    {
713                        Some(result) => result,
714                        None => return true,
715                    };
716
717                if let Some(cb) = on_toggle.as_ref() {
718                    cb.emit(MultiSelectToggleEvent {
719                        index: active_index,
720                        selected: toggled_to_selected,
721                    });
722                }
723                if let Some(cb) = on_change.as_ref() {
724                    cb.emit(MultiSelectChangeEvent {
725                        selected_indices: next_selected,
726                    });
727                }
728
729                true
730            }));
731        } else if let Some(handler) = multi.on_key {
732            list = list.on_key(handler);
733        }
734
735        list.into()
736    }
737}
738
739fn multi_select_list_item(
740    item: &MultiSelectItem,
741    checked: bool,
742    description_style: Style,
743    description_placement: MultiSelectDescriptionPlacement,
744    description_overflow: MultiSelectDescriptionOverflow,
745    description_selection: bool,
746) -> ListItem {
747    let overflow = effective_description_overflow(description_placement, description_overflow);
748
749    let mut list_item = if let Some(description) = &item.description {
750        match description_placement {
751            MultiSelectDescriptionPlacement::Inline => ListItem::from_spans([
752                Span::new(item.label.clone()),
753                Span::new(" - ").style(description_style),
754                Span::new(description.clone()).style(description_style),
755            ]),
756            MultiSelectDescriptionPlacement::Right => ListItem::new(item.label.clone())
757                .description_spans([
758                    Span::new(" ").style(description_style),
759                    Span::new(description.clone()).style(description_style),
760                ])
761                .primary_selection_description(true)
762                .primary_hover_description(true)
763                .primary_truncate_description_first(true),
764            MultiSelectDescriptionPlacement::Above => {
765                ListItem::from_spans([Span::new(description.clone()).style(description_style)])
766                    .primary_selection_label(description_selection)
767                    .primary_selection_description(description_selection)
768                    .primary_hover_label(description_selection)
769                    .primary_hover_description(description_selection)
770                    .primary_wrap_label(matches!(overflow, MultiSelectDescriptionOverflow::Wrap))
771                    .symbol_line(1)
772                    .line(ListItemLine::new(item.label.clone()))
773            }
774            MultiSelectDescriptionPlacement::Below => ListItem::new(item.label.clone()).line(
775                ListItemLine::new(description.clone())
776                    .style(description_style)
777                    .selection_label(description_selection)
778                    .selection_description(description_selection)
779                    .hover_label(description_selection)
780                    .hover_description(description_selection)
781                    .wrap_label(matches!(overflow, MultiSelectDescriptionOverflow::Wrap)),
782            ),
783        }
784    } else {
785        ListItem::new(item.label.clone())
786    };
787
788    list_item = list_item.active(checked);
789    list_item
790}
791
792fn effective_description_overflow(
793    placement: MultiSelectDescriptionPlacement,
794    overflow: MultiSelectDescriptionOverflow,
795) -> MultiSelectDescriptionOverflow {
796    match placement {
797        MultiSelectDescriptionPlacement::Above | MultiSelectDescriptionPlacement::Below => overflow,
798        MultiSelectDescriptionPlacement::Inline | MultiSelectDescriptionPlacement::Right => {
799            MultiSelectDescriptionOverflow::Truncate
800        }
801    }
802}
803
804fn normalized_active_index(active_index: usize, len: usize) -> usize {
805    if len == 0 {
806        0
807    } else {
808        active_index.min(len.saturating_sub(1))
809    }
810}
811
812fn normalize_selected_indices(mut selected_indices: Vec<usize>, len: usize) -> Vec<usize> {
813    selected_indices.retain(|index| *index < len);
814    selected_indices.sort_unstable();
815    selected_indices.dedup();
816    selected_indices
817}
818
819fn toggle_selection(
820    selected_indices: &[usize],
821    index: usize,
822    max_selected: Option<usize>,
823) -> Option<(Vec<usize>, bool)> {
824    let mut next = selected_indices.to_vec();
825
826    match next.binary_search(&index) {
827        Ok(index) => {
828            next.remove(index);
829            Some((next, false))
830        }
831        Err(insert_index) => {
832            if let Some(limit) = max_selected
833                && next.len() >= limit
834            {
835                return None;
836            }
837            next.insert(insert_index, index);
838            Some((next, true))
839        }
840    }
841}
842
843#[cfg(test)]
844mod tests {
845    use super::{
846        MultiSelectDescriptionOverflow, MultiSelectDescriptionPlacement, MultiSelectItem,
847        multi_select_list_item, normalize_selected_indices, toggle_selection,
848    };
849    use crate::style::Style;
850
851    #[test]
852    fn normalize_selected_indices_sorts_and_dedups() {
853        let selected = normalize_selected_indices(vec![4, 2, 2, 9, 1], 5);
854        assert_eq!(selected, vec![1, 2, 4]);
855    }
856
857    #[test]
858    fn toggle_selection_adds_and_removes() {
859        let (selected, added) = toggle_selection(&[1, 3], 2, None).expect("should toggle");
860        assert!(added);
861        assert_eq!(selected, vec![1, 2, 3]);
862
863        let (selected, added) = toggle_selection(&[1, 2, 3], 2, None).expect("should toggle");
864        assert!(!added);
865        assert_eq!(selected, vec![1, 3]);
866    }
867
868    #[test]
869    fn toggle_selection_respects_limit() {
870        let toggled = toggle_selection(&[0, 1], 2, Some(2));
871        assert!(toggled.is_none());
872    }
873
874    #[test]
875    fn above_description_keeps_label_on_secondary_line() {
876        let item = MultiSelectItem::new("Label").description("Desc");
877        let rendered = multi_select_list_item(
878            &item,
879            false,
880            Style::default(),
881            MultiSelectDescriptionPlacement::Above,
882            MultiSelectDescriptionOverflow::Truncate,
883            false,
884        );
885
886        let primary: String = rendered
887            .spans
888            .iter()
889            .map(|span| span.content.as_ref())
890            .collect();
891        let secondary: String = rendered.extra_lines[0]
892            .spans
893            .iter()
894            .map(|span| span.content.as_ref())
895            .collect();
896
897        assert_eq!(primary, "Desc");
898        assert_eq!(secondary, "Label");
899    }
900
901    #[test]
902    fn right_description_always_highlights_and_hovers() {
903        let item = MultiSelectItem::new("Label").description("Desc");
904        let rendered = multi_select_list_item(
905            &item,
906            false,
907            Style::default(),
908            MultiSelectDescriptionPlacement::Right,
909            MultiSelectDescriptionOverflow::Wrap,
910            false,
911        );
912
913        assert!(rendered.primary_selection_description);
914        assert!(rendered.primary_hover_description);
915        assert!(!rendered.description_spans.is_empty());
916        assert_eq!(rendered.description_spans[0].content.as_ref(), " ");
917    }
918
919    #[test]
920    fn below_wrap_sets_wrap_label_flag() {
921        let item = MultiSelectItem::new("Label").description("Desc");
922        let rendered = multi_select_list_item(
923            &item,
924            false,
925            Style::default(),
926            MultiSelectDescriptionPlacement::Below,
927            MultiSelectDescriptionOverflow::Wrap,
928            true,
929        );
930
931        assert_eq!(rendered.extra_lines.len(), 1);
932        assert!(rendered.extra_lines[0].wrap_label);
933    }
934
935    #[test]
936    fn items_arc_preserves_shared_slice() {
937        use super::MultiSelect;
938        use std::sync::Arc;
939
940        let items: Arc<[MultiSelectItem]> =
941            Arc::from([MultiSelectItem::new("a"), MultiSelectItem::new("b")]);
942        let multi = MultiSelect::new().items_arc(Arc::clone(&items));
943        assert!(Arc::ptr_eq(&multi.items, &items));
944    }
945}