1use std::cell::Cell;
9use std::rc::Rc;
10
11use gpui::{
12 AnyElement, App, Bounds, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
13 IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, Render, ScrollHandle,
14 SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
15};
16use gpui_kit_assets::{Icon, icon};
17use gpui_kit_semantics::{NodeSpec, Role, Semantic};
18use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
19
20use crate::foundation::{
21 Disableable, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
22};
23use crate::layout::measure;
24use crate::motion;
25use crate::overlay::Placement;
26use crate::overlay::popover::{self, MenuKey};
27use crate::strings::{ActiveStrings, StringKey};
28
29const MENU_MIN_WIDTH: f32 = 180.0;
30const MENU_MAX_HEIGHT: f32 = 320.0;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SelectOption {
35 pub id: SharedString,
36 pub label: SharedString,
37 pub description: Option<SharedString>,
38 pub disabled: bool,
39}
40
41impl SelectOption {
42 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
43 Self {
44 id: id.into(),
45 label: label.into(),
46 description: None,
47 disabled: false,
48 }
49 }
50
51 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
52 self.description = Some(description.into());
53 self
54 }
55
56 pub fn disabled(mut self, disabled: bool) -> Self {
57 self.disabled = disabled;
58 self
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum SelectEvent {
65 Selected(SharedString),
67 Opened,
68 Closed,
69}
70
71impl EventEmitter<SelectEvent> for Select {}
72
73pub struct Select {
79 ident: Ident,
80 focus_handle: FocusHandle,
81 options: Vec<SelectOption>,
82 selected: Option<SharedString>,
83 name: SharedString,
84 placeholder: Option<SharedString>,
85 size: ControlSize,
86 disabled: bool,
87 invalid: bool,
88 open: bool,
89 active: Option<usize>,
91 scroll: ScrollHandle,
92 trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
93 reveal_active: bool,
94 menu_geometry: Option<popover::MenuGeometry>,
95}
96
97impl std::fmt::Debug for Select {
98 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 formatter
100 .debug_struct("Select")
101 .field("ident", &self.ident)
102 .field("options", &self.options.len())
103 .field("selected", &self.selected)
104 .field("open", &self.open)
105 .field("disabled", &self.disabled)
106 .finish()
107 }
108}
109
110impl Select {
111 pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
112 Self {
113 ident: ident.into(),
114 focus_handle: cx.focus_handle(),
115 options: Vec::new(),
116 selected: None,
117 name: SharedString::default(),
118 placeholder: None,
119 size: ControlSize::Md,
120 disabled: false,
121 invalid: false,
122 open: false,
123 active: None,
124 scroll: ScrollHandle::new(),
125 trigger_bounds: Rc::default(),
126 reveal_active: false,
127 menu_geometry: None,
128 }
129 }
130
131 pub fn options(mut self, options: impl IntoIterator<Item = SelectOption>) -> Self {
132 self.options = options.into_iter().collect();
133 self
134 }
135
136 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
137 self.selected = Some(id.into());
138 self
139 }
140
141 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
143 self.name = name.into();
144 self
145 }
146
147 pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
148 self.name = name.into();
149 cx.notify();
150 }
151
152 fn resolved_placeholder(&self, cx: &App) -> SharedString {
154 self.placeholder
155 .clone()
156 .unwrap_or_else(|| cx.strings().text(StringKey::SelectPlaceholder))
157 }
158
159 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
160 self.placeholder = Some(placeholder.into());
161 self
162 }
163
164 pub fn invalid(mut self, invalid: bool) -> Self {
165 self.invalid = invalid;
166 self
167 }
168
169 pub fn set_options(&mut self, options: Vec<SelectOption>, cx: &mut Context<Self>) {
172 let still_offered = self
173 .selected
174 .as_ref()
175 .is_some_and(|id| options.iter().any(|option| &option.id == id));
176 if !still_offered {
177 self.selected = None;
178 }
179 self.options = options;
180 self.active = None;
181 self.reveal_active = true;
182 cx.notify();
183 }
184
185 pub fn set_selected(&mut self, id: Option<SharedString>, cx: &mut Context<Self>) {
186 self.selected = id;
187 if self.open {
188 self.active = self
189 .selected
190 .as_ref()
191 .and_then(|id| self.options.iter().position(|option| &option.id == id))
192 .filter(|index| !self.options[*index].disabled)
193 .or_else(|| self.first_selectable(0, 1));
194 }
195 self.reveal_active = true;
196 cx.notify();
197 }
198
199 pub fn selected_id(&self) -> Option<&SharedString> {
200 self.selected.as_ref()
201 }
202
203 pub fn selected_option(&self) -> Option<&SelectOption> {
204 let id = self.selected.as_ref()?;
205 self.options.iter().find(|option| &option.id == id)
206 }
207
208 pub fn is_open(&self) -> bool {
209 self.open
210 }
211
212 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
213 self.disabled = disabled;
214 if disabled {
215 self.open = false;
216 }
217 cx.notify();
218 }
219
220 fn open_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
221 if self.disabled || self.open {
222 return;
223 }
224 self.open = true;
225 self.active = self
228 .selected
229 .as_ref()
230 .and_then(|id| self.options.iter().position(|option| &option.id == id))
231 .filter(|index| !self.options[*index].disabled)
232 .or_else(|| self.first_selectable(0, 1));
233 self.reveal_active = true;
234 window.focus(&self.focus_handle, cx);
235 cx.emit(SelectEvent::Opened);
236 cx.notify();
237 }
238
239 fn close_menu(&mut self, cx: &mut Context<Self>) {
240 if !self.open {
241 return;
242 }
243 self.open = false;
244 self.active = None;
245 cx.emit(SelectEvent::Closed);
246 cx.notify();
247 }
248
249 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
250 if self.open {
251 self.close_menu(cx);
252 } else {
253 self.open_menu(window, cx);
254 }
255 }
256
257 fn first_selectable(&self, from: usize, delta: isize) -> Option<usize> {
259 let count = self.options.len();
260 if count == 0 {
261 return None;
262 }
263 let mut index = from.min(count - 1);
264 for _ in 0..count {
265 if !self.options[index].disabled {
266 return Some(index);
267 }
268 index = ((index as isize + delta).rem_euclid(count as isize)) as usize;
269 }
270 None
271 }
272
273 fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
274 let Some(next) = popover::step(self.active, self.options.len(), delta) else {
275 return;
276 };
277 self.active = self.first_selectable(next, delta.signum());
278 self.reveal_active = true;
279 cx.notify();
280 }
281
282 fn edge(&mut self, from_end: bool, cx: &mut Context<Self>) {
283 let next = if from_end {
284 self.options
285 .len()
286 .checked_sub(1)
287 .and_then(|index| self.first_selectable(index, -1))
288 } else {
289 self.first_selectable(0, 1)
290 };
291 if next == self.active {
292 return;
293 }
294 self.active = next;
295 self.reveal_active = true;
296 cx.notify();
297 }
298
299 fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
300 let Some(option) = self.options.get(index) else {
301 return;
302 };
303 if option.disabled {
304 return;
305 }
306 let id = option.id.clone();
307 self.open = false;
308 self.active = None;
309 cx.emit(SelectEvent::Selected(id));
310 cx.emit(SelectEvent::Closed);
311 cx.notify();
312 }
313
314 fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
315 if self.disabled {
316 return;
317 }
318 let raw = event.keystroke.key.as_str();
319 let key = popover::classify_key(
320 raw,
321 event.keystroke.modifiers.platform,
322 event.keystroke.modifiers.control,
323 );
324 match (self.open, key) {
325 (false, MenuKey::Down | MenuKey::Up | MenuKey::Enter) => {
326 self.open_menu(window, cx);
327 cx.stop_propagation();
328 }
329 (true, MenuKey::Down) => {
330 self.step(1, cx);
331 cx.stop_propagation();
332 }
333 (true, MenuKey::Up) => {
334 self.step(-1, cx);
335 cx.stop_propagation();
336 }
337 (true, _) if raw == "home" => {
338 self.edge(false, cx);
339 cx.stop_propagation();
340 }
341 (true, _) if raw == "end" => {
342 self.edge(true, cx);
343 cx.stop_propagation();
344 }
345 (true, MenuKey::Enter) => {
346 if let Some(active) = self.active {
347 self.choose(active, cx);
348 }
349 cx.stop_propagation();
350 }
351 (true, MenuKey::Escape) => {
352 self.close_menu(cx);
353 cx.stop_propagation();
354 }
355 _ => {}
356 }
357 }
358
359 fn menu(&mut self, geometry: popover::MenuGeometry, cx: &mut Context<Self>) -> AnyElement {
360 let theme = cx.theme().clone();
361 if self.menu_geometry != Some(geometry) {
362 self.menu_geometry = Some(geometry);
363 self.reveal_active = true;
364 }
365 if self.reveal_active {
366 if let Some(active) = self.active {
367 self.scroll.scroll_to_item(active);
368 }
369 self.reveal_active = false;
370 }
371 let rows = self
372 .options
373 .iter()
374 .enumerate()
375 .map(|(index, option)| self.row(index, option, self.options.len(), cx))
376 .collect::<Vec<_>>();
377
378 let viewport = div()
379 .p(px(theme.space(Space::Xs)))
380 .flex()
381 .flex_col()
382 .max_h(px(geometry.max_height))
383 .id(self.ident.child("menu.scroll").element_id())
384 .overflow_y_scroll()
385 .track_scroll(&self.scroll)
386 .children(rows);
387 let list = popover::card_flush(&theme)
388 .w(px(geometry.width))
389 .max_h(px(geometry.max_height))
390 .id(self.ident.child("menu").element_id())
391 .child(viewport)
392 .semantic_in(
393 cx,
394 NodeSpec::new(self.ident.child("menu").semantic_id(), Role::Menu),
395 )
396 .into_any_element();
397
398 popover::menu_overlay(
399 &self.ident.child("menu.anchor"),
400 &theme,
401 geometry.placement,
402 list,
403 )
404 }
405
406 fn row(
407 &self,
408 index: usize,
409 option: &SelectOption,
410 count: usize,
411 cx: &mut Context<Self>,
412 ) -> AnyElement {
413 let theme = cx.theme().clone();
414 let selected = self.selected.as_ref() == Some(&option.id);
415 let active = self.active == Some(index);
416 let ident = self.ident.child(option.id.as_ref());
417 let hover_group = ident.child("hover").semantic_id();
418
419 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Option)
420 .parent(self.ident.child("menu").semantic_id())
421 .checked(selected)
422 .disabled(option.disabled)
423 .text(option.label.clone());
424 if active {
425 spec = spec.hovered(true);
426 }
427
428 let row = popover::menu_row(&theme, selected, active)
429 .id(ident.element_id())
430 .group(hover_group.clone())
431 .when(!option.disabled, |element| {
432 element.cursor_pointer().pressable(cx)
433 })
434 .when(option.disabled, |element| {
435 element.opacity(theme.opacity.disabled)
436 })
437 .child(
438 div()
439 .flex()
440 .flex_col()
441 .flex_1()
442 .min_w_0()
443 .gap(px(2.0))
444 .child(popover::menu_label(
445 &theme,
446 option.label.clone(),
447 selected,
448 active,
449 hover_group,
450 ))
451 .when_some(option.description.clone(), |element, description| {
452 element.child(
453 foundation_text(&theme, TypeScale::Caption, description)
454 .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
455 )
456 }),
457 )
458 .when(selected, |element| {
459 element.child(
460 div().ml_auto().child(
461 icon(Icon::Check)
462 .size(px(14.0))
463 .text_color(theme.colors.text),
464 ),
465 )
466 })
467 .when(!option.disabled, |element| {
468 element.on_mouse_down(
469 MouseButton::Left,
470 cx.listener(move |select, _, _, cx| {
471 select.choose(index, cx);
472 }),
473 )
474 })
475 .semantic_in(cx, spec);
476
477 motion::row_in(ident.child("in").element_id(), &theme, index, count, row).into_any_element()
478 }
479}
480
481impl Disableable for Select {
482 fn disabled(mut self, disabled: bool) -> Self {
483 self.disabled = disabled;
484 self
485 }
486}
487
488impl Sizable for Select {
489 fn control_size(mut self, size: ControlSize) -> Self {
490 self.size = size;
491 self
492 }
493}
494
495impl Focusable for Select {
496 fn focus_handle(&self, _cx: &App) -> FocusHandle {
497 self.focus_handle.clone()
498 }
499}
500
501impl Render for Select {
502 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
503 let theme = cx.theme().clone();
504 let metrics = theme.control.get(self.size);
505 let focused = self.focus_handle.is_focused(window);
506 let label = self
507 .selected_option()
508 .map(|option| option.label.clone())
509 .unwrap_or_else(|| self.resolved_placeholder(cx));
510 let has_choice = self.selected_option().is_some();
511
512 let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Combobox)
513 .disabled(self.disabled)
514 .invalid(self.invalid)
515 .expanded(self.open)
516 .text(self.name.clone())
517 .placeholder(self.resolved_placeholder(cx));
518 if !self.disabled {
519 spec = spec.focus(&self.focus_handle);
520 }
521 if let Some(option) = self.selected_option() {
522 spec = spec.value(option.label.clone());
523 }
524
525 let geometry = self.open.then(|| {
526 popover::menu_geometry(
527 window,
528 self.trigger_bounds.get(),
529 &theme,
530 MENU_MAX_HEIGHT,
531 MENU_MIN_WIDTH,
532 )
533 });
534 let placement = geometry.map_or(Placement::Below, |geometry| geometry.placement);
535 let menu = geometry.map(|geometry| self.menu(geometry, cx));
536
537 let trigger = div()
538 .id(self.ident.element_id())
539 .when(!self.disabled, |element| {
540 element
541 .track_focus(&self.focus_handle)
542 .on_key_down(cx.listener(Self::on_key_down))
543 })
544 .w_full()
545 .flex()
546 .flex_row()
547 .items_center()
548 .justify_between()
549 .gap(px(theme.space(Space::Sm)))
550 .h(px(metrics.height))
551 .px(px(metrics.padding_x))
552 .radius(&theme, Radius::Control)
553 .well(&theme)
554 .when(self.invalid, |element| {
555 element.border_color(theme.colors.danger)
556 })
557 .when(focused, |element| element.shadow(theme.focus_ring()))
558 .when(self.disabled, |element| {
559 element.opacity(theme.opacity.disabled)
560 })
561 .when(!self.disabled, |element| {
562 element.cursor_pointer().on_mouse_down(
563 MouseButton::Left,
564 cx.listener(|select, _, window, cx| select.toggle(window, cx)),
565 )
566 })
567 .child(
568 foundation_text(&theme, TypeScale::Label, label)
569 .text_size(px(metrics.font_size))
570 .text_color(if self.disabled || !has_choice {
571 theme.colors.text_faint
572 } else {
573 theme.colors.text
574 }),
575 )
576 .child(
577 icon(Icon::AltArrowDown)
580 .size(px(metrics.icon_size * 0.9))
581 .text_color(theme.colors.text_muted),
582 )
583 .semantic_in(cx, spec);
584 let measured = Rc::clone(&self.trigger_bounds);
585 let trigger = div()
586 .w_full()
587 .on_children_prepainted(move |bounds, window, _| {
588 if let Some(trigger) = bounds.first() {
589 measure::record(&measured, *trigger, window);
590 }
591 })
592 .child(trigger)
593 .into_any_element();
594
595 popover::anchored_slot(placement, trigger, menu)
596 }
597}