Skip to main content

guise/input/
select.rs

1//! `Select` — a stateful dropdown picker (gpui entity).
2//!
3//! Owns its open state and selection; renders a trigger plus a deferred
4//! dropdown list, and emits [`SelectEvent`] when the choice changes.
5
6use gpui::prelude::*;
7use gpui::{
8    deferred, div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, SharedString,
9    Window,
10};
11
12use super::control_metrics;
13use crate::devtools::Probed;
14use crate::reactive::Signal;
15use crate::theme::{theme, Size};
16
17/// Emitted when the user picks an option. Carries the option index.
18#[derive(Debug, Clone)]
19pub struct SelectEvent(pub usize);
20
21/// A dropdown picker. Create with `cx.new(|cx| Select::new(cx).data([...]))`.
22pub struct Select {
23    options: Vec<SharedString>,
24    selected: Option<usize>,
25    open: bool,
26    focus: FocusHandle,
27    placeholder: SharedString,
28    label: Option<SharedString>,
29    size: Size,
30    disabled: bool,
31}
32
33impl EventEmitter<SelectEvent> for Select {}
34
35impl Select {
36    pub fn new(cx: &mut Context<Self>) -> Self {
37        Select {
38            options: Vec::new(),
39            selected: None,
40            open: false,
41            focus: cx.focus_handle(),
42            placeholder: SharedString::new_static("Pick one"),
43            label: None,
44            size: Size::Sm,
45            disabled: false,
46        }
47    }
48
49    pub fn data<I, S>(mut self, options: I) -> Self
50    where
51        I: IntoIterator<Item = S>,
52        S: Into<SharedString>,
53    {
54        self.options = options.into_iter().map(Into::into).collect();
55        self.selected = self.selected.and_then(|index| {
56            (!self.options.is_empty()).then(|| index.min(self.options.len() - 1))
57        });
58        self
59    }
60
61    pub fn selected(mut self, index: usize) -> Self {
62        self.selected = Some(index);
63        self
64    }
65
66    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
67        self.placeholder = placeholder.into();
68        self
69    }
70
71    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
72        self.label = Some(label.into());
73        self
74    }
75
76    pub fn size(mut self, size: Size) -> Self {
77        self.size = size;
78        self
79    }
80
81    pub fn disabled(mut self, disabled: bool) -> Self {
82        self.disabled = disabled;
83        self
84    }
85
86    pub fn selected_index(&self) -> Option<usize> {
87        self.selected
88    }
89
90    pub fn selected_value(&self) -> Option<SharedString> {
91        self.selected.and_then(|i| self.options.get(i).cloned())
92    }
93
94    /// Two-way bind this picker's selection to a `Signal<usize>`. The signal
95    /// is the source of truth: the picker adopts its index now, picks write
96    /// back through [`Signal::set_if_changed`], and signal writes move the
97    /// selection without emitting [`SelectEvent`]. Equality guards on both
98    /// directions prevent update loops.
99    pub fn bind(entity: &Entity<Select>, signal: &Signal<usize>, cx: &mut App) {
100        let initial = signal.get(cx);
101        entity.update(cx, |this, cx| this.sync_selected(initial, cx));
102        let sink = signal.clone();
103        cx.subscribe(entity, move |_select, event: &SelectEvent, cx| {
104            sink.set_if_changed(cx, event.0);
105        })
106        .detach();
107        let select = entity.downgrade();
108        cx.observe(signal.entity(), move |observed, cx| {
109            let index = *observed.read(cx);
110            select
111                .update(cx, |this, cx| this.sync_selected(index, cx))
112                .ok();
113        })
114        .detach();
115    }
116
117    /// Programmatic set: repaint without emitting an event.
118    fn sync_selected(&mut self, index: usize, cx: &mut Context<Self>) {
119        let selected = (!self.options.is_empty()).then(|| index.min(self.options.len() - 1));
120        if self.selected != selected {
121            self.selected = selected;
122            cx.notify();
123        }
124    }
125}
126
127impl Render for Select {
128    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
129        let t = theme(cx);
130        let (height, pad_x, font) = control_metrics(self.size);
131        let radius = t.radius(t.default_radius);
132        let surface = t.surface().hsla();
133        let surface_hover = t.surface_hover().hsla();
134        let border = t.border().hsla();
135        let text_color = t.text().hsla();
136        let dimmed = t.dimmed().hsla();
137        let selected_bg = t.primary().alpha(0.12);
138        let font_sm = t.font_size(Size::Sm);
139
140        let selected = self.selected;
141        let chosen = selected.and_then(|i| self.options.get(i));
142        let has_value = chosen.is_some();
143        let value_text: SharedString = chosen.cloned().unwrap_or_else(|| self.placeholder.clone());
144
145        let trigger = div()
146            .id("guise-select-trigger")
147            .track_focus(&self.focus)
148            .flex()
149            .items_center()
150            .justify_between()
151            .gap(px(8.0))
152            .h(px(height))
153            .px(px(pad_x))
154            .rounded(px(radius))
155            .border_1()
156            .border_color(border)
157            .bg(surface)
158            .text_size(px(font))
159            .text_color(if has_value { text_color } else { dimmed })
160            .child(value_text)
161            .child(
162                div()
163                    .text_color(dimmed)
164                    .child(SharedString::new_static("\u{25be}")),
165            )
166            .on_click(cx.listener(|this, _ev, _window, cx| {
167                if !this.disabled {
168                    this.open = !this.open;
169                    cx.notify();
170                }
171            }));
172
173        let mut wrap = div().relative().child(trigger);
174
175        if self.open && !self.disabled {
176            let mut menu = div()
177                .absolute()
178                .top(px(height + 6.0))
179                .left(px(0.0))
180                .right(px(0.0))
181                .flex()
182                .flex_col()
183                .gap(px(2.0))
184                .p(px(4.0))
185                .rounded(px(radius))
186                .border_1()
187                .border_color(border)
188                .bg(surface)
189                .shadow_md();
190
191            for (i, option) in self.options.iter().enumerate() {
192                let is_selected = Some(i) == selected;
193                let mut row = div()
194                    .id(("guise-select-option", i))
195                    .px(px(10.0))
196                    .py(px(6.0))
197                    .rounded(px(4.0))
198                    .text_size(px(font))
199                    .text_color(text_color)
200                    .hover(move |s| s.bg(surface_hover))
201                    .child(option.clone())
202                    .on_click(cx.listener(move |this, _ev, _window, cx| {
203                        this.selected = Some(i);
204                        this.open = false;
205                        cx.emit(SelectEvent(i));
206                        cx.notify();
207                    }));
208                if is_selected {
209                    row = row.bg(selected_bg);
210                }
211                menu = menu.child(row);
212            }
213
214            wrap = wrap.child(deferred(menu));
215        }
216
217        let mut column = div().flex().flex_col().gap(px(4.0));
218        if let Some(label) = self.label.clone() {
219            column = column.child(
220                div()
221                    .text_size(px(font_sm))
222                    .text_color(text_color)
223                    .child(label),
224            );
225        }
226        column = column.child(wrap);
227
228        let element = if self.disabled {
229            column.opacity(0.6)
230        } else {
231            column
232        };
233
234        element.probe("Select")
235    }
236}