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