1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4 App, Bounds, Context, ElementId, Entity, FocusHandle, Focusable, Hsla, MouseButton, Pixels,
5 Render, SharedString, Window, actions, prelude::*,
6};
7use liora_core::{Config, push_portal};
8use liora_icons::Icon;
9use liora_icons_lucide::IconName;
10
11actions!(select, [SelectClose]);
12
13pub struct Select {
14 options: Vec<SharedString>,
15 selected_idx: Option<usize>,
16 is_open: bool,
17 focus_handle: FocusHandle,
18 last_bounds: Option<Bounds<Pixels>>,
19 on_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
20 border_none: bool,
21 radius_none: bool,
22 radius_left_none: bool,
23 radius_right_none: bool,
24 width: Option<Pixels>,
25 text_size: Option<Pixels>,
26 text_color: Option<Hsla>,
27 padding_x: Option<Pixels>,
28 close_on_click_outside: bool,
29 close_on_escape: bool,
30}
31
32impl Select {
33 pub fn new(
34 options: Vec<impl Into<SharedString>>,
35 selected_idx: Option<usize>,
36 cx: &mut Context<Self>,
37 ) -> Self {
38 Self {
39 options: options.into_iter().map(|o| o.into()).collect(),
40 selected_idx,
41 is_open: false,
42 focus_handle: cx.focus_handle(),
43 last_bounds: None,
44 on_change: None,
45 border_none: false,
46 radius_none: false,
47 radius_left_none: false,
48 radius_right_none: false,
49 width: None,
50 text_size: None,
51 text_color: None,
52 padding_x: None,
53 close_on_click_outside: true,
54 close_on_escape: true,
55 }
56 }
57
58 pub fn borderless(mut self) -> Self {
59 self.border_none = true;
60 self
61 }
62 pub fn radius_none(mut self) -> Self {
63 self.radius_none = true;
64 self
65 }
66 pub fn radius_left_none(mut self) -> Self {
67 self.radius_left_none = true;
68 self
69 }
70 pub fn radius_right_none(mut self) -> Self {
71 self.radius_right_none = true;
72 self
73 }
74 pub fn width(mut self, w: impl Into<Pixels>) -> Self {
75 self.width = Some(w.into());
76 self
77 }
78
79 pub fn width_xs(self) -> Self {
80 self.width(gpui::px(90.0))
81 }
82
83 pub fn text_size(mut self, s: impl Into<Pixels>) -> Self {
84 self.text_size = Some(s.into());
85 self
86 }
87
88 pub fn text_sm(self) -> Self {
89 self.text_size(gpui::px(14.0))
90 }
91 pub fn text_color(mut self, c: Hsla) -> Self {
92 self.text_color = Some(c);
93 self
94 }
95 pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
96 self.padding_x = Some(p.into());
97 self
98 }
99
100 pub fn padding_x_sm(self) -> Self {
101 self.padding_x(gpui::px(8.0))
102 }
103
104 pub fn set_borderless(&mut self, b: bool, cx: &mut Context<Self>) {
105 if self.border_none == b {
106 return;
107 }
108 self.border_none = b;
109 cx.notify();
110 }
111
112 pub fn set_radius_none(&mut self, r: bool, cx: &mut Context<Self>) {
113 if self.radius_none == r {
114 return;
115 }
116 self.radius_none = r;
117 cx.notify();
118 }
119
120 pub fn set_radius_left_none(&mut self, r: bool, cx: &mut Context<Self>) {
121 if self.radius_left_none == r {
122 return;
123 }
124 self.radius_left_none = r;
125 cx.notify();
126 }
127
128 pub fn set_radius_right_none(&mut self, r: bool, cx: &mut Context<Self>) {
129 if self.radius_right_none == r {
130 return;
131 }
132 self.radius_right_none = r;
133 cx.notify();
134 }
135
136 pub fn set_width(&mut self, w: impl Into<Pixels>, cx: &mut Context<Self>) {
137 let w = w.into();
138 if self.width == Some(w) {
139 return;
140 }
141 self.width = Some(w);
142 cx.notify();
143 }
144
145 pub fn set_text_size(&mut self, s: impl Into<Pixels>, cx: &mut Context<Self>) {
146 let s = s.into();
147 if self.text_size == Some(s) {
148 return;
149 }
150 self.text_size = Some(s);
151 cx.notify();
152 }
153
154 pub fn set_text_color(&mut self, c: Hsla, cx: &mut Context<Self>) {
155 if self.text_color == Some(c) {
156 return;
157 }
158 self.text_color = Some(c);
159 cx.notify();
160 }
161
162 pub fn set_padding_x(&mut self, p: impl Into<Pixels>, cx: &mut Context<Self>) {
163 let p = p.into();
164 if self.padding_x == Some(p) {
165 return;
166 }
167 self.padding_x = Some(p);
168 cx.notify();
169 }
170
171 pub fn set_options(&mut self, options: Vec<SharedString>, cx: &mut Context<Self>) {
172 if self.options == options {
173 return;
174 }
175 self.options = options;
176 if let Some(idx) = self.selected_idx
177 && idx >= self.options.len()
178 {
179 self.selected_idx = None;
180 }
181 cx.notify();
182 }
183
184 pub fn set_selected_idx(&mut self, idx: Option<usize>, cx: &mut Context<Self>) {
185 if self.selected_idx == idx {
186 return;
187 }
188 self.selected_idx = idx;
189 cx.notify();
190 }
191
192 pub fn close_on_escape(mut self, close: bool) -> Self {
193 self.close_on_escape = close;
194 self
195 }
196
197 pub fn close_on_click_outside(mut self, close: bool) -> Self {
198 self.close_on_click_outside = close;
199 self
200 }
201
202 pub fn register_key_bindings(cx: &mut App) {
203 cx.bind_keys([gpui::KeyBinding::new("escape", SelectClose, None)]);
204 }
205
206 fn close_on_escape_action(&mut self, _: &SelectClose, _: &mut Window, cx: &mut Context<Self>) {
207 if self.close_on_escape && self.is_open {
208 self.is_open = false;
209 cx.notify();
210 }
211 }
212
213 pub fn on_change(mut self, cb: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
214 self.on_change = Some(Box::new(cb));
215 self
216 }
217
218 pub fn set_on_change(&mut self, cb: impl Fn(usize, &mut Window, &mut App) + 'static) {
219 self.on_change = Some(Box::new(cb));
220 }
221
222 pub fn selected_index(&self) -> Option<usize> {
223 self.selected_idx
224 }
225
226 fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
227 self.is_open = !self.is_open;
228 if self.is_open {
229 window.focus(&self.focus_handle);
230 }
231 cx.notify();
232 }
233
234 fn select_option(&mut self, idx: usize, window: &mut Window, cx: &mut Context<Self>) {
235 self.selected_idx = Some(idx);
236 self.is_open = false;
237 if let Some(ref cb) = self.on_change {
238 cb(idx, window, cx);
239 }
240 cx.notify();
241 }
242}
243
244impl Focusable for Select {
245 fn focus_handle(&self, _cx: &App) -> FocusHandle {
246 self.focus_handle.clone()
247 }
248}
249
250struct BoundsCapturer {
251 select: Entity<Select>,
252}
253
254impl IntoElement for BoundsCapturer {
255 type Element = Self;
256 fn into_element(self) -> Self::Element {
257 self
258 }
259}
260
261impl Element for BoundsCapturer {
262 type RequestLayoutState = ();
263 type PrepaintState = ();
264
265 fn id(&self) -> Option<ElementId> {
266 None
267 }
268 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
269 None
270 }
271
272 fn request_layout(
273 &mut self,
274 _: Option<&gpui::GlobalElementId>,
275 _: Option<&gpui::InspectorElementId>,
276 window: &mut Window,
277 cx: &mut App,
278 ) -> (gpui::LayoutId, ()) {
279 let mut style = gpui::Style::default();
280 style.size.width = gpui::relative(1.0).into();
281 style.size.height = gpui::relative(1.0).into();
282 (window.request_layout(style, [], cx), ())
283 }
284
285 fn prepaint(
286 &mut self,
287 _: Option<&gpui::GlobalElementId>,
288 _: Option<&gpui::InspectorElementId>,
289 bounds: Bounds<Pixels>,
290 _: &mut (),
291 _window: &mut Window,
292 cx: &mut App,
293 ) -> () {
294 self.select.update(cx, |this, _| {
295 this.last_bounds = Some(bounds);
296 });
297 }
298
299 fn paint(
300 &mut self,
301 _: Option<&gpui::GlobalElementId>,
302 _: Option<&gpui::InspectorElementId>,
303 _: Bounds<Pixels>,
304 _: &mut (),
305 _: &mut (),
306 _window: &mut Window,
307 _: &mut App,
308 ) {
309 }
310}
311
312impl Render for Select {
313 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
314 let config = cx.global::<Config>();
315 let theme = config.theme.clone();
316 let focused = self.focus_handle.is_focused(_window);
317
318 let display_text = self
319 .selected_idx
320 .map(|i| self.options[i].clone())
321 .unwrap_or_else(|| "Select...".into());
322
323 let border_color = if focused || self.is_open {
324 theme.primary.base
325 } else {
326 theme.neutral.border
327 };
328 let text_size = self.text_size.unwrap_or(gpui::px(theme.font_size.md));
329 let text_color = self.text_color.unwrap_or(theme.neutral.text_1);
330 let h_px = self.padding_x.unwrap_or(gpui::px(12.0));
331
332 let trigger_content = gpui::div()
333 .flex()
334 .flex_row()
335 .items_center()
336 .justify_between()
337 .w_full()
338 .h(gpui::px(34.0))
339 .px(h_px)
340 .child(
341 gpui::div()
342 .text_size(text_size)
343 .text_color(text_color)
344 .child(display_text),
345 )
346 .child(
347 Icon::new(if self.is_open {
348 IconName::ChevronUp
349 } else {
350 IconName::ChevronDown
351 })
352 .size(gpui::px(14.0))
353 .color(theme.neutral.icon),
354 );
355
356 if self.is_open {
357 let options = self.options.clone();
358 let selected_idx = self.selected_idx;
359 let entity = cx.entity().clone();
360 let theme_portal = theme.clone();
361 let trigger_bounds = self.last_bounds;
362
363 push_portal(
364 move |_window, _cx| {
365 let (top, left, width) = if let Some(b) = trigger_bounds {
366 (b.bottom() + gpui::px(4.0), b.left(), b.size.width)
367 } else {
368 (gpui::px(100.0), gpui::px(100.0), gpui::px(200.0))
369 };
370
371 let entity = entity.clone();
372 let theme = theme_portal.clone();
373
374 let panel = gpui::div()
375 .absolute()
376 .top(top)
377 .left(left)
378 .w(width)
379 .max_h(gpui::px(200.0))
380 .bg(theme.neutral.card)
381 .rounded(gpui::px(theme.radius.md))
382 .border_1()
383 .border_color(theme.neutral.border)
384 .shadow(vec![gpui::BoxShadow {
385 color: theme.neutral.border,
386 offset: gpui::point(gpui::px(0.0), gpui::px(4.0)),
387 blur_radius: gpui::px(12.0),
388 spread_radius: gpui::px(0.0),
389 }])
390 .children(options.iter().enumerate().map(|(idx, label)| {
391 let is_selected = Some(idx) == selected_idx;
392 let entity = entity.clone();
393 let theme = theme.clone();
394 let label = label.clone();
395
396 gpui::div()
397 .id(element_id(format!("select-option-{}", idx)))
398 .px(gpui::px(12.0))
399 .py(gpui::px(8.0))
400 .cursor_pointer()
401 .bg(if is_selected {
402 theme.primary.base.opacity(0.1)
403 } else {
404 theme.neutral.card
405 })
406 .hover(move |s| {
407 s.cursor_pointer().bg(if is_selected {
408 theme.neutral.text_3.opacity(0.16)
409 } else {
410 theme.neutral.hover
411 })
412 })
413 .child(
414 gpui::div()
415 .text_size(gpui::px(theme.font_size.md))
416 .text_color(if is_selected {
417 theme.primary.base
418 } else {
419 theme.neutral.text_1
420 })
421 .child(label),
422 )
423 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
424 entity.update(cx, |this, cx| {
425 this.select_option(idx, window, cx);
426 });
427 })
428 }));
429
430 pop_in(
431 element_id(format!("liora-select-panel-motion-{}", entity.entity_id())),
432 panel,
433 )
434 .into_any_element()
435 },
436 cx,
437 );
438 }
439
440 let mut el = gpui::div()
441 .relative()
442 .when_some(self.width, |s, w| s.w(w))
443 .when(self.width.is_none(), |s| s.w_full())
444 .bg(theme.neutral.card)
445 .when(!self.border_none, |s| {
446 s.border_1().border_color(border_color)
447 })
448 .cursor_pointer()
449 .hover(|s| {
450 let s = s.cursor_pointer();
451 if self.border_none {
452 s
453 } else {
454 s.border_color(theme.primary.base)
455 }
456 });
457
458 if !self.radius_none {
459 if self.radius_left_none {
460 el = el.rounded_r(gpui::px(theme.radius.md));
461 } else if self.radius_right_none {
462 el = el.rounded_l(gpui::px(theme.radius.md));
463 } else {
464 el = el.rounded(gpui::px(theme.radius.md));
465 }
466 }
467
468 let close_on_click_outside = self.close_on_click_outside;
469
470 el.child(trigger_content)
471 .child(
472 gpui::div()
473 .absolute()
474 .top_0()
475 .left_0()
476 .size_full()
477 .child(BoundsCapturer {
478 select: cx.entity().clone(),
479 }),
480 )
481 .on_mouse_down(
482 MouseButton::Left,
483 cx.listener(|this, _, window, cx| {
484 this.toggle_open(window, cx);
485 }),
486 )
487 .when(close_on_click_outside, |s| {
488 s.on_mouse_down_out(cx.listener(|this, _, _, cx| {
489 this.is_open = false;
490 cx.notify();
491 }))
492 })
493 .on_action(cx.listener(Self::close_on_escape_action))
494 }
495}