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