Skip to main content

ui/components/
multi_select.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{AnyElement, Bounds, Context, Pixels, Render, anchored, canvas, deferred, point};
4
5use crate::prelude::*;
6use crate::{Chip, ToggleState};
7
8/// A multi-value select: a trigger showing selected values as dismissible
9/// `Chip`s plus a `DropdownMenu`-style checklist popover; toggling an option
10/// adds/removes its `Chip`. Stateful view — create with
11/// `cx.new(|_| MultiSelect::new(options))`.
12pub struct MultiSelect {
13    options: Vec<SharedString>,
14    selected: Vec<usize>,
15    open: bool,
16    placeholder: SharedString,
17    /// Real screen bounds of the trigger row, captured via an invisible
18    /// `canvas()` measurement child every render and read back on the
19    /// *next* render to position the floating option list. See
20    /// `Select::trigger_bounds` for the full rationale.
21    trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
22}
23
24impl MultiSelect {
25    pub fn new(options: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
26        Self {
27            options: options.into_iter().map(Into::into).collect(),
28            selected: Vec::new(),
29            open: false,
30            placeholder: "Select options…".into(),
31            trigger_bounds: Rc::new(Cell::new(None)),
32        }
33    }
34
35    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
36        self.placeholder = placeholder.into();
37        self
38    }
39
40    pub fn selected_indices(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
41        self.selected = indices.into_iter().collect();
42        self
43    }
44
45    /// The currently selected option labels.
46    pub fn values(&self) -> Vec<&SharedString> {
47        self.selected
48            .iter()
49            .filter_map(|&i| self.options.get(i))
50            .collect()
51    }
52}
53
54impl Render for MultiSelect {
55    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
56        let open = self.open;
57        let has_selection = !self.selected.is_empty();
58
59        let mut chips_row = h_flex().flex_1().flex_wrap().gap_1();
60        if has_selection {
61            for &i in &self.selected {
62                if let Some(label) = self.options.get(i).cloned() {
63                    chips_row = chips_row.child(Chip::new(label).pill(true).dismissible(
64                        cx.listener(move |this, _, _, cx| {
65                            this.selected.retain(|&x| x != i);
66                            cx.notify();
67                        }),
68                    ));
69                }
70            }
71        } else {
72            chips_row =
73                chips_row.child(Label::new(self.placeholder.clone()).color(Color::Placeholder));
74        }
75
76        let trigger = h_flex()
77            .id("multi-select-trigger")
78            // Test-only (no-op in release builds, per `debug_selector`'s own
79            // doc comment): lets integration tests locate the trigger's real
80            // rendered pixel bounds via `VisualTestContext::debug_bounds`.
81            .debug_selector(|| "MULTI-SELECT-TRIGGER".into())
82            .w_full()
83            .min_h(px(40.))
84            .items_center()
85            .justify_between()
86            .gap_2()
87            .px_3()
88            .py_2()
89            .rounded_md()
90            .bg(semantic::surface(cx))
91            .border_1()
92            .border_color(if open {
93                palette::primary(500)
94            } else {
95                semantic::border(cx)
96            })
97            .cursor_pointer()
98            .on_click(cx.listener(|this, _, _, cx| {
99                this.open = !this.open;
100                cx.notify();
101            }))
102            .child(chips_row)
103            .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
104            .child({
105                let trigger_bounds = self.trigger_bounds.clone();
106                canvas(
107                    move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
108                    |_bounds, _state, _window, _cx| {},
109                )
110                .absolute()
111                .top_0()
112                .left_0()
113                .size_full()
114            });
115
116        let trigger_width = px(280.);
117
118        v_flex()
119            .w(trigger_width)
120            .gap_1()
121            .child(trigger)
122            .when(open, |this| {
123                let hover = semantic::hover_bg(cx);
124                let mut list = v_flex()
125                    .w(trigger_width)
126                    .p_1()
127                    .rounded_md()
128                    .bg(semantic::elevated_surface(cx))
129                    .border_1()
130                    .border_color(semantic::border(cx))
131                    .shadow_level(Shadow::Lg);
132
133                for (i, option) in self.options.iter().enumerate() {
134                    let checked = self.selected.contains(&i);
135                    let option = option.clone();
136                    list = list.child(
137                        h_flex()
138                            .id(("multi-select-option", i))
139                            .w_full()
140                            .items_center()
141                            .gap_2()
142                            .px_3()
143                            .py_2()
144                            .rounded_md()
145                            .cursor_pointer()
146                            .hover(move |s| s.bg(hover))
147                            .on_click(cx.listener(move |this, _, _, cx| {
148                                if let Some(pos) = this.selected.iter().position(|&x| x == i) {
149                                    this.selected.remove(pos);
150                                } else {
151                                    this.selected.push(i);
152                                }
153                                cx.notify();
154                            }))
155                            .child(
156                                Checkbox::new(
157                                    ("multi-select-check", i),
158                                    if checked {
159                                        ToggleState::Selected
160                                    } else {
161                                        ToggleState::Unselected
162                                    },
163                                )
164                                .visualization_only(true),
165                            )
166                            .child(Label::new(option)),
167                    );
168                }
169
170                // Float the list in a `deferred` overlay pass, anchored just
171                // below the trigger's real (previous-frame) bounds, instead
172                // of an inline flow child — so it never pushes sibling
173                // content down. Same idiom as `PopoverMenu`/`ContextMenu`
174                // (`crates/ui/src/components/popover_menu.rs`,
175                // `crates/ui/src/components/context_menu.rs`).
176                let mut anchor = anchored().snap_to_window_with_margin(px(8.));
177                if let Some(bounds) = self.trigger_bounds.get() {
178                    anchor = anchor.position(point(
179                        bounds.origin.x,
180                        bounds.origin.y + bounds.size.height + px(4.),
181                    ));
182                }
183                let floating_list = deferred(
184                    anchor.child(
185                        div()
186                            .occlude()
187                            .debug_selector(|| "MULTI-SELECT-LIST".into())
188                            .child(list),
189                    ),
190                )
191                .with_priority(1);
192                this.child(floating_list)
193            })
194    }
195}
196
197/// Standalone gallery preview for `MultiSelect` (not registered in the
198/// `Component` catalog since it is a stateful `Entity`, matching `Select`'s
199/// existing convention in this crate).
200pub fn multi_select_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
201    v_flex()
202        .gap_4()
203        .child(cx.new(|_| {
204            MultiSelect::new(["Design", "Engineering", "Marketing", "Sales", "Support"])
205                .selected_indices([0, 2])
206        }))
207        .into_any_element()
208}