Skip to main content

ui/components/
select.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{Bounds, Context, Pixels, Render, anchored, canvas, deferred, point};
4
5use crate::prelude::*;
6
7/// A dropdown select: a trigger styled like a text field plus an expandable
8/// option list. Stateful view — create with `cx.new(|_| Select::new(options))`.
9pub struct Select {
10    options: Vec<SharedString>,
11    selected: Option<usize>,
12    open: bool,
13    placeholder: SharedString,
14    /// Real screen bounds of the trigger row, captured via an invisible
15    /// `canvas()` measurement child every render (regardless of `open`
16    /// state) and read back on the *next* render to position the floating
17    /// option list. One-render-frame lag, same idiom as
18    /// `ContextMenu::submenu_trigger_bounds` (see
19    /// `crates/ui/src/components/context_menu.rs`); converges before the
20    /// list is ever visible to the user since the trigger renders (and thus
21    /// measures) on every frame, open or closed.
22    trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
23}
24
25impl Select {
26    pub fn new(options: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
27        Self {
28            options: options.into_iter().map(Into::into).collect(),
29            selected: None,
30            open: false,
31            placeholder: "Select…".into(),
32            trigger_bounds: Rc::new(Cell::new(None)),
33        }
34    }
35
36    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
37        self.placeholder = placeholder.into();
38        self
39    }
40
41    pub fn selected_index(mut self, index: usize) -> Self {
42        self.selected = Some(index);
43        self
44    }
45
46    /// The currently selected option text, if any.
47    pub fn value(&self) -> Option<&SharedString> {
48        self.selected.and_then(|i| self.options.get(i))
49    }
50}
51
52impl Render for Select {
53    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
54        let label: SharedString = self
55            .selected
56            .and_then(|i| self.options.get(i).cloned())
57            .unwrap_or_else(|| self.placeholder.clone());
58        let has_value = self.selected.is_some();
59
60        let trigger = h_flex()
61            .id("select-trigger")
62            // Test-only (no-op in release builds, per `debug_selector`'s own
63            // doc comment): lets integration tests locate the trigger's real
64            // rendered pixel bounds via `VisualTestContext::debug_bounds`.
65            // Mirrors the existing `Tab`/`SegmentedControl` precedent.
66            .debug_selector(|| "SELECT-TRIGGER".into())
67            .w_full()
68            .items_center()
69            .justify_between()
70            .px_3()
71            .py_2()
72            .rounded_md()
73            .bg(semantic::surface(cx))
74            .border_1()
75            .border_color(if self.open {
76                palette::primary(500)
77            } else {
78                semantic::border(cx)
79            })
80            .cursor_pointer()
81            .on_click(cx.listener(|this, _, _, cx| {
82                this.open = !this.open;
83                cx.notify();
84            }))
85            .child(Label::new(label).color(if has_value {
86                Color::Default
87            } else {
88                Color::Placeholder
89            }))
90            .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
91            .child({
92                let trigger_bounds = self.trigger_bounds.clone();
93                canvas(
94                    move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
95                    |_bounds, _state, _window, _cx| {},
96                )
97                .absolute()
98                .top_0()
99                .left_0()
100                .size_full()
101            });
102
103        let trigger_width = px(240.);
104
105        v_flex()
106            .w(trigger_width)
107            .gap_1()
108            .child(trigger)
109            .when(self.open, |this| {
110                let hover = semantic::hover_bg(cx);
111                let mut list = v_flex()
112                    .w(trigger_width)
113                    .p_1()
114                    .rounded_md()
115                    .bg(semantic::elevated_surface(cx))
116                    .border_1()
117                    .border_color(semantic::border(cx))
118                    .shadow_level(Shadow::Lg);
119                for (i, option) in self.options.iter().enumerate() {
120                    let option = option.clone();
121                    list = list.child(
122                        h_flex()
123                            .id(("select-option", i))
124                            .debug_selector(move || format!("SELECT-OPTION-{i}"))
125                            .w_full()
126                            .px_3()
127                            .py_2()
128                            .rounded_md()
129                            .cursor_pointer()
130                            .hover(move |s| s.bg(hover))
131                            .on_click(cx.listener(move |this, _, _, cx| {
132                                this.selected = Some(i);
133                                this.open = false;
134                                cx.notify();
135                            }))
136                            .child(Label::new(option)),
137                    );
138                }
139
140                // Float the list in a `deferred` overlay pass, anchored just
141                // below the trigger's real (previous-frame) bounds, instead
142                // of an inline flow child — so it never pushes sibling
143                // content down. Same idiom as `PopoverMenu`/`ContextMenu`
144                // (`crates/ui/src/components/popover_menu.rs`,
145                // `crates/ui/src/components/context_menu.rs`).
146                let mut anchor = anchored().snap_to_window_with_margin(px(8.));
147                if let Some(bounds) = self.trigger_bounds.get() {
148                    anchor = anchor.position(point(
149                        bounds.origin.x,
150                        bounds.origin.y + bounds.size.height + px(4.),
151                    ));
152                }
153                let floating_list = deferred(
154                    anchor.child(
155                        div()
156                            .occlude()
157                            .debug_selector(|| "SELECT-LIST".into())
158                            .child(list),
159                    ),
160                )
161                .with_priority(1);
162                this.child(floating_list)
163            })
164    }
165}