1use 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#[derive(Debug, Clone)]
19pub struct SelectEvent(pub usize);
20
21pub 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 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 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}