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