Skip to main content

tui_lipan/widgets/select/
mod.rs

1//! Select widget.
2
3use std::sync::Arc;
4
5use crate::callback::{Callback, KeyHandler};
6use crate::core::element::Element;
7use crate::core::event::{KeyCode, KeyEvent, MouseEvent};
8use crate::style::{BorderStyle, Length, Padding, ScrollbarConfig, Style, StyleSlot};
9use crate::widgets::button::ButtonVariant;
10use crate::widgets::internal::scroll_action_from_key;
11use crate::widgets::{
12    Button, List, ListConfig, ListItem, Popover, PopoverPlacement, ScrollKeymap, ZStack,
13};
14
15/// A dropdown select widget (expands in-place).
16#[derive(Clone)]
17pub struct Select {
18    pub(crate) options: Vec<Arc<str>>,
19    pub(crate) selected: Option<usize>,
20    pub(crate) placeholder: Arc<str>,
21    pub(crate) expanded: bool,
22    pub(crate) on_toggle: Option<Callback<bool>>,
23    pub(crate) on_select: Option<Callback<usize>>,
24    pub(crate) on_change: Option<Callback<usize>>,
25    pub(crate) width: Length,
26    pub(crate) disabled: bool,
27    pub(crate) button_variant: ButtonVariant,
28    pub(crate) button_style: Style,
29    pub(crate) button_hover_style: StyleSlot,
30    pub(crate) button_focus_style: StyleSlot,
31    pub(crate) button_disabled_style: Style,
32    pub(crate) button_border_style: BorderStyle,
33    pub(crate) button_hover_border_style: Option<BorderStyle>,
34    pub(crate) button_focus_border_style: Option<BorderStyle>,
35    pub(crate) button_open_suffix: Option<Arc<str>>,
36    pub(crate) button_closed_suffix: Option<Arc<str>>,
37    pub(crate) button_suffix_style: Style,
38    pub(crate) list_title: Option<Arc<str>>,
39    pub(crate) list_title_style: Style,
40    pub(crate) list_config: ListConfig,
41
42    pub(crate) list_width: Option<Length>,
43    pub(crate) list_height: Length,
44    pub(crate) match_button_width: bool,
45    pub(crate) list_empty_text: Option<Arc<str>>,
46    pub(crate) list_disabled_style: Style,
47    pub(crate) focusable: bool,
48    pub(crate) tab_stop: bool,
49    pub(crate) on_focus: Option<Callback<()>>,
50    pub(crate) on_blur: Option<Callback<()>>,
51    pub(crate) on_key: Option<KeyHandler>,
52}
53
54impl Default for Select {
55    fn default() -> Self {
56        Self {
57            options: Vec::new(),
58            selected: None,
59            placeholder: "Select...".into(),
60            expanded: false,
61            on_toggle: None,
62            on_select: None,
63            on_change: None,
64            width: Length::Auto,
65            disabled: false,
66            button_variant: ButtonVariant::Outlined,
67            button_style: Style::default(),
68            button_hover_style: StyleSlot::Inherit,
69            button_focus_style: StyleSlot::Inherit,
70            button_disabled_style: Style::default(),
71            button_border_style: BorderStyle::Plain,
72            button_hover_border_style: None,
73            button_focus_border_style: None,
74            button_open_suffix: None,
75            button_closed_suffix: None,
76            button_suffix_style: Style::default(),
77            list_title: None,
78            list_title_style: Style::default(),
79            list_config: ListConfig {
80                border: true,
81                border_style: BorderStyle::Plain,
82                padding: Padding::default(),
83                style: Style::default(),
84                selection_style: StyleSlot::Inherit,
85                unfocused_selection_style: StyleSlot::Inherit,
86                selection_full_width: false,
87                selection_symbol: Some("> ".into()),
88                selection_symbol_right: None,
89                selection_symbol_style: None,
90                unfocused_selection_symbol_style: None,
91                symbol_column: true,
92                gutter_gap: 0,
93                gutter_for_non_selectable: false,
94                item_horizontal_padding: Padding::default(),
95                header_horizontal_padding: Padding::default(),
96                empty_text_style: Style::default(),
97                item_hover_style: None,
98                scrollbar: false,
99                scrollbar_config: ScrollbarConfig::default(),
100            },
101            list_width: None,
102            list_height: Length::Px(6),
103            match_button_width: false,
104            list_empty_text: None,
105            list_disabled_style: Style::default(),
106            focusable: true,
107            tab_stop: true,
108            on_focus: None,
109            on_blur: None,
110            on_key: None,
111        }
112    }
113}
114
115impl Select {
116    /// Create a new select.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Set options.
122    pub fn options(mut self, options: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
123        self.options = options.into_iter().map(Into::into).collect();
124        self
125    }
126
127    /// Set selected index.
128    pub fn selected(mut self, selected: Option<usize>) -> Self {
129        self.selected = selected;
130        self
131    }
132
133    /// Set placeholder text.
134    pub fn placeholder(mut self, placeholder: impl Into<Arc<str>>) -> Self {
135        self.placeholder = placeholder.into();
136        self
137    }
138
139    /// Set expanded state.
140    pub fn expanded(mut self, expanded: bool) -> Self {
141        self.expanded = expanded;
142        self
143    }
144
145    /// Set toggle callback.
146    pub fn on_toggle(mut self, cb: Callback<bool>) -> Self {
147        self.on_toggle = Some(cb);
148        self
149    }
150
151    /// Set selection callback.
152    pub fn on_select(mut self, cb: Callback<usize>) -> Self {
153        self.on_select = Some(cb);
154        self
155    }
156
157    /// Set callback when selection changes.
158    pub fn on_change(mut self, cb: Callback<usize>) -> Self {
159        self.on_change = Some(cb);
160        self
161    }
162
163    /// Set width.
164    pub fn width(mut self, width: Length) -> Self {
165        self.width = width;
166        self
167    }
168
169    /// Set disabled state.
170    pub fn disabled(mut self, disabled: bool) -> Self {
171        self.disabled = disabled;
172        self
173    }
174
175    /// Control whether the trigger button is focusable.
176    pub fn focusable(mut self, focusable: bool) -> Self {
177        self.focusable = focusable;
178        self
179    }
180
181    /// Control whether the trigger button participates in tab traversal.
182    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
183        self.tab_stop = tab_stop;
184        self
185    }
186
187    /// Set the callback fired when the trigger button gains focus.
188    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
189        self.on_focus = Some(cb);
190        self
191    }
192
193    /// Set the callback fired when the trigger button loses focus.
194    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
195        self.on_blur = Some(cb);
196        self
197    }
198
199    /// Set focused key handler. Returning `true` consumes the key before built-in navigation.
200    pub fn on_key(mut self, handler: KeyHandler) -> Self {
201        self.on_key = Some(handler);
202        self
203    }
204
205    /// Set button variant.
206    pub fn button_variant(mut self, variant: ButtonVariant) -> Self {
207        self.button_variant = variant;
208        self
209    }
210
211    /// Set button style.
212    pub fn button_style(mut self, style: Style) -> Self {
213        self.button_style = style;
214        self
215    }
216
217    /// Set button hover style.
218    pub fn button_hover_style(mut self, style: Style) -> Self {
219        self.button_hover_style = StyleSlot::Replace(style);
220        self
221    }
222
223    /// Extend the themed button hover style.
224    pub fn extend_button_hover_style(mut self, style: Style) -> Self {
225        self.button_hover_style = StyleSlot::Extend(style);
226        self
227    }
228
229    /// Inherit the themed button hover style.
230    pub fn inherit_button_hover_style(mut self) -> Self {
231        self.button_hover_style = StyleSlot::Inherit;
232        self
233    }
234
235    /// Set button hover style slot directly for composite forwarding.
236    pub fn button_hover_style_slot(mut self, slot: StyleSlot) -> Self {
237        self.button_hover_style = slot;
238        self
239    }
240
241    /// Set button focus style.
242    pub fn button_focus_style(mut self, style: Style) -> Self {
243        self.button_focus_style = StyleSlot::Replace(style);
244        self
245    }
246
247    /// Extend the themed button focus style.
248    pub fn extend_button_focus_style(mut self, style: Style) -> Self {
249        self.button_focus_style = StyleSlot::Extend(style);
250        self
251    }
252
253    /// Inherit the themed button focus style.
254    pub fn inherit_button_focus_style(mut self) -> Self {
255        self.button_focus_style = StyleSlot::Inherit;
256        self
257    }
258
259    /// Set button focus style slot directly for composite forwarding.
260    pub fn button_focus_style_slot(mut self, slot: StyleSlot) -> Self {
261        self.button_focus_style = slot;
262        self
263    }
264
265    /// Set button disabled style.
266    pub fn button_disabled_style(mut self, style: Style) -> Self {
267        self.button_disabled_style = style;
268        self
269    }
270
271    /// Set button border style (used for outlined variant).
272    pub fn button_border_style(mut self, style: BorderStyle) -> Self {
273        self.button_border_style = style;
274        self
275    }
276
277    /// Set button border style while hovered.
278    pub fn button_hover_border_style(mut self, style: BorderStyle) -> Self {
279        self.button_hover_border_style = Some(style);
280        self
281    }
282
283    /// Set button border style while focused.
284    pub fn button_focus_border_style(mut self, style: BorderStyle) -> Self {
285        self.button_focus_border_style = Some(style);
286        self
287    }
288
289    /// Set suffix shown when dropdown is open.
290    pub fn button_open_suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
291        self.button_open_suffix = Some(suffix.into());
292        self
293    }
294
295    /// Set suffix shown when dropdown is closed.
296    pub fn button_closed_suffix(mut self, suffix: impl Into<Arc<str>>) -> Self {
297        self.button_closed_suffix = Some(suffix.into());
298        self
299    }
300
301    /// Set suffix style.
302    pub fn button_suffix_style(mut self, style: Style) -> Self {
303        self.button_suffix_style = style;
304        self
305    }
306
307    /// Set dropdown title.
308    pub fn list_title(mut self, title: impl Into<Arc<str>>) -> Self {
309        self.list_title = Some(title.into());
310        self
311    }
312
313    /// Set dropdown title style.
314    pub fn list_title_style(mut self, style: Style) -> Self {
315        self.list_title_style = style;
316        self
317    }
318
319    /// Set list config.
320    pub fn list_config(mut self, config: ListConfig) -> Self {
321        self.list_config = config;
322        self
323    }
324
325    /// Set list border.
326    pub fn list_border(mut self, border: bool) -> Self {
327        self.list_config.border = border;
328        self
329    }
330
331    /// Set list border style.
332    pub fn list_border_style(mut self, style: BorderStyle) -> Self {
333        self.list_config.border_style = style;
334        self
335    }
336
337    /// Set list padding.
338    pub fn list_padding(mut self, padding: impl Into<Padding>) -> Self {
339        self.list_config.padding = padding.into();
340        self
341    }
342
343    /// Set list style.
344    pub fn list_style(mut self, style: Style) -> Self {
345        self.list_config.style = style;
346        self
347    }
348
349    /// Set list highlight style.
350    pub fn list_selection_style(mut self, style: Style) -> Self {
351        self.list_config.selection_style = StyleSlot::Replace(style);
352        self
353    }
354
355    /// Extend the themed dropdown highlight style.
356    pub fn extend_list_selection_style(mut self, style: Style) -> Self {
357        self.list_config.selection_style = StyleSlot::Extend(style);
358        self
359    }
360
361    /// Inherit the themed dropdown highlight style.
362    pub fn inherit_list_selection_style(mut self) -> Self {
363        self.list_config.selection_style = StyleSlot::Inherit;
364        self
365    }
366
367    /// Set list highlight style slot directly for composite forwarding.
368    pub fn list_selection_style_slot(mut self, slot: StyleSlot) -> Self {
369        self.list_config.selection_style = slot;
370        self
371    }
372
373    /// Set dropdown highlight style while the list is not focused.
374    pub fn list_unfocused_selection_style(mut self, style: Style) -> Self {
375        self.list_config.unfocused_selection_style = StyleSlot::Replace(style);
376        self
377    }
378
379    /// Extend the themed dropdown highlight style while the list is not focused.
380    pub fn extend_list_unfocused_selection_style(mut self, style: Style) -> Self {
381        self.list_config.unfocused_selection_style = StyleSlot::Extend(style);
382        self
383    }
384
385    /// Inherit the themed dropdown highlight style while the list is not focused.
386    pub fn inherit_list_unfocused_selection_style(mut self) -> Self {
387        self.list_config.unfocused_selection_style = StyleSlot::Inherit;
388        self
389    }
390
391    /// Set list unfocused highlight style slot directly for composite forwarding.
392    pub fn list_unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
393        self.list_config.unfocused_selection_style = slot;
394        self
395    }
396
397    /// Set whether active_index style spans full list row width.
398    pub fn list_selection_full_width(mut self, full_width: bool) -> Self {
399        self.list_config.selection_full_width = full_width;
400        self
401    }
402
403    /// Set list highlight symbol.
404    pub fn list_selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
405        self.list_config.selection_symbol = symbol.map(Into::into);
406        self
407    }
408
409    /// Set the trailing list selection symbol (right "pill" cap). Pairs with
410    /// [`Self::list_selection_symbol`] and shares the selection symbol style.
411    pub fn list_selection_symbol_right(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
412        self.list_config.selection_symbol_right = symbol.map(Into::into);
413        self
414    }
415
416    /// Set list highlight symbol style.
417    pub fn list_selection_symbol_style(mut self, style: Style) -> Self {
418        self.list_config.selection_symbol_style = Some(style);
419        self
420    }
421
422    /// Set list highlight symbol style while the list is not focused.
423    pub fn list_unfocused_selection_symbol_style(mut self, style: Style) -> Self {
424        self.list_config.unfocused_selection_symbol_style = Some(style);
425        self
426    }
427
428    /// Set list hover style.
429    pub fn list_hover_style(mut self, style: Style) -> Self {
430        self.list_config.item_hover_style = Some(StyleSlot::Replace(style));
431        self
432    }
433
434    /// Extend the themed list hover style.
435    pub fn extend_list_hover_style(mut self, style: Style) -> Self {
436        self.list_config.item_hover_style = Some(StyleSlot::Extend(style));
437        self
438    }
439
440    /// Inherit the themed list hover style.
441    pub fn inherit_list_hover_style(mut self) -> Self {
442        self.list_config.item_hover_style = Some(StyleSlot::Inherit);
443        self
444    }
445
446    /// Set list hover style slot directly for composite forwarding.
447    pub fn list_hover_style_slot(mut self, slot: StyleSlot) -> Self {
448        self.list_config.item_hover_style = Some(slot);
449        self
450    }
451
452    /// Set list width.
453    pub fn list_width(mut self, width: Length) -> Self {
454        self.list_width = Some(width);
455        self
456    }
457
458    /// Set list height.
459    pub fn list_height(mut self, height: Length) -> Self {
460        self.list_height = height;
461        self
462    }
463
464    /// Force dropdown width to exactly match trigger button width.
465    pub fn match_button_width(mut self, match_button_width: bool) -> Self {
466        self.match_button_width = match_button_width;
467        self
468    }
469
470    /// Enable list scrollbar.
471    pub fn list_scrollbar(mut self, scrollbar: bool) -> Self {
472        self.list_config.scrollbar = scrollbar;
473        self
474    }
475
476    /// Set list scrollbar configuration.
477    pub fn list_scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
478        self.list_config.scrollbar_config = config;
479        self
480    }
481
482    /// Set dropdown empty text.
483    pub fn list_empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
484        self.list_empty_text = Some(text.into());
485        self
486    }
487
488    /// Set dropdown empty text style.
489    pub fn list_empty_text_style(mut self, style: Style) -> Self {
490        self.list_config.empty_text_style = style;
491        self
492    }
493
494    /// Set dropdown disabled style.
495    pub fn list_disabled_style(mut self, style: Style) -> Self {
496        self.list_disabled_style = style;
497        self
498    }
499}
500
501impl From<Select> for Element {
502    fn from(select: Select) -> Self {
503        let label = if let Some(idx) = select.selected {
504            select
505                .options
506                .get(idx)
507                .cloned()
508                .unwrap_or(select.placeholder.clone())
509        } else {
510            select.placeholder.clone()
511        };
512
513        let mut button = Button::new(label)
514            .variant(select.button_variant)
515            .width(select.width)
516            .style(select.button_style)
517            .hover_style_slot(select.button_hover_style)
518            .focus_style_slot(select.button_focus_style)
519            .disabled_style(select.button_disabled_style)
520            .disabled(select.disabled)
521            .focusable(select.focusable)
522            .tab_stop(select.tab_stop);
523
524        if let Some(cb) = select.on_focus.clone() {
525            button = button.on_focus(cb);
526        }
527        if let Some(cb) = select.on_blur.clone() {
528            button = button.on_blur(cb);
529        }
530
531        if matches!(select.button_variant, ButtonVariant::Outlined) {
532            button = button.border_style(select.button_border_style);
533        }
534        if let Some(style) = select.button_hover_border_style {
535            button = button.hover_border_style(Some(style));
536        }
537        if let Some(style) = select.button_focus_border_style {
538            button = button.focus_border_style(Some(style));
539        }
540
541        let suffix = if select.expanded {
542            select.button_open_suffix.clone()
543        } else {
544            select.button_closed_suffix.clone()
545        };
546        if let Some(suffix) = suffix {
547            button = button
548                .shortcut(suffix)
549                .shortcut_style(select.button_suffix_style);
550        }
551
552        if let Some(cb) = select.on_toggle.clone()
553            && !select.disabled
554        {
555            let expanded = select.expanded;
556            button = button.on_click(Callback::new(move |_: MouseEvent| cb.emit(!expanded)));
557        }
558
559        if select.expanded && !select.disabled {
560            let options_len = select.options.len();
561            let selected = select
562                .selected
563                .unwrap_or(0)
564                .min(options_len.saturating_sub(1));
565            let change_cb = select.on_change.clone().or(select.on_select.clone());
566            let on_select = select.on_select.clone();
567            let on_toggle = select.on_toggle.clone();
568            let caller_on_key = select.on_key.clone();
569            button = button.on_key(KeyHandler::new(move |key: KeyEvent| {
570                if caller_on_key
571                    .as_ref()
572                    .is_some_and(|handler| handler.handle(key))
573                {
574                    return true;
575                }
576
577                if key.code == KeyCode::Esc
578                    && let Some(toggle) = &on_toggle
579                {
580                    toggle.emit(false);
581                    return true;
582                }
583
584                if key.code == KeyCode::Enter {
585                    let mut handled = false;
586                    if let Some(cb) = &on_select {
587                        cb.emit(selected);
588                        handled = true;
589                    }
590                    if let Some(toggle) = &on_toggle {
591                        toggle.emit(false);
592                        handled = true;
593                    }
594                    return handled;
595                }
596
597                let Some(action) = scroll_action_from_key(&key, ScrollKeymap::default()) else {
598                    return false;
599                };
600
601                if options_len == 0 {
602                    return true;
603                }
604
605                if let Some(next) = List::selection_for_action_in_len(selected, options_len, action)
606                    && next != selected
607                    && let Some(cb) = &change_cb
608                {
609                    cb.emit(next);
610                }
611
612                true
613            }));
614        } else if let Some(handler) = select.on_key {
615            button = button.on_key(handler);
616        }
617
618        let list_hover_slot = select
619            .list_config
620            .item_hover_style
621            .unwrap_or(select.list_config.selection_style);
622
623        let mut list = List::new()
624            .items(select.options.iter().map(|s| ListItem::new(s.clone())))
625            .selected(select.selected.unwrap_or(0))
626            .title_style(select.list_title_style)
627            .border(select.list_config.border)
628            .border_style(select.list_config.border_style)
629            .padding(select.list_config.padding)
630            .style(select.list_config.style)
631            .selection_full_width(select.list_config.selection_full_width)
632            .selection_symbol_style(
633                select.list_config.selection_symbol_style.unwrap_or(
634                    select
635                        .list_config
636                        .selection_style
637                        .explicit_style()
638                        .unwrap_or_default(),
639                ),
640            )
641            .unfocused_selection_symbol_style(
642                select
643                    .list_config
644                    .unfocused_selection_symbol_style
645                    .or_else(|| {
646                        select
647                            .list_config
648                            .unfocused_selection_style
649                            .explicit_style()
650                    })
651                    .unwrap_or(
652                        select
653                            .list_config
654                            .selection_style
655                            .explicit_style()
656                            .unwrap_or_default(),
657                    ),
658            )
659            .selection_symbol(select.list_config.selection_symbol)
660            .selection_symbol_right(select.list_config.selection_symbol_right)
661            .symbol_column(select.list_config.symbol_column)
662            .gutter_gap(select.list_config.gutter_gap)
663            .gutter_for_non_selectable(select.list_config.gutter_for_non_selectable)
664            .scrollbar(select.list_config.scrollbar)
665            .scrollbar_config(select.list_config.scrollbar_config)
666            .width(select.list_width.unwrap_or(select.width))
667            .height(select.list_height)
668            .item_horizontal_padding(select.list_config.item_horizontal_padding)
669            .header_horizontal_padding(select.list_config.header_horizontal_padding)
670            .empty_text_style(select.list_config.empty_text_style)
671            .disabled_style(select.list_disabled_style)
672            .disabled(select.disabled);
673        list = list
674            .selection_style_slot(select.list_config.selection_style)
675            .unfocused_selection_style_slot(select.list_config.unfocused_selection_style)
676            .item_hover_style_slot(list_hover_slot);
677
678        if let Some(title) = select.list_title {
679            list = list.title(title);
680        }
681        if let Some(empty_text) = select.list_empty_text {
682            list = list.empty_text(empty_text);
683        }
684
685        let change_cb = select.on_change.clone().or(select.on_select.clone());
686        if let Some(cb) = change_cb {
687            list = list.on_select(Callback::new(move |ev: crate::widgets::ListEvent| {
688                cb.emit(ev.index);
689            }));
690        }
691
692        let emit_on_activate = select.on_change.is_some();
693        if (emit_on_activate && select.on_select.is_some()) || select.on_toggle.is_some() {
694            let on_select = select.on_select.clone();
695            let on_toggle = select.on_toggle.clone();
696            list = list.on_activate(Callback::new(move |ev: crate::widgets::ListEvent| {
697                if emit_on_activate && let Some(cb) = &on_select {
698                    cb.emit(ev.index);
699                }
700                if let Some(toggle) = &on_toggle {
701                    toggle.emit(false);
702                }
703            }));
704        }
705
706        let overlay = ZStack::new().child(list);
707        let mut popover = Popover::new()
708            .trigger(button)
709            .content(overlay)
710            .open(select.expanded && !select.disabled)
711            .fit_trigger_width(select.match_button_width && select.list_width.is_none())
712            .placement(PopoverPlacement::BelowStart);
713
714        if let Some(cb) = select.on_toggle.clone() {
715            popover = popover.on_close(Callback::new(move |_| cb.emit(false)));
716        }
717
718        popover.into()
719    }
720}