Skip to main content

gpui_kit/controls/
select.rs

1//! A control for choosing one of a known set of options.
2//!
3//! The open menu is transient view state, so `Select` is a view rather than a
4//! builder. The chosen value is not: the select reports what was picked and
5//! renders whatever the owner decides is current, so a host that rejects a
6//! choice keeps showing the one that still holds.
7
8use 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/// One choice, identified by business identity rather than by position.
33#[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/// What a [`Select`] reports. The owner decides what any of it means.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum SelectEvent {
65    /// The typist picked this option. The owner decides whether it holds.
66    Selected(SharedString),
67    Opened,
68    Closed,
69}
70
71impl EventEmitter<SelectEvent> for Select {}
72
73/// A closed list of options with one answer.
74///
75/// The select owns only whether its menu is open. It reports the option that
76/// was picked and draws whatever the caller says is current, so a refused
77/// choice is visible as the checkmark not moving.
78pub 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    /// Which row the keyboard is on, which is not a choice until it is taken.
90    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    /// Names the control independently of its current answer or placeholder.
142    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    /// The placeholder the host gave, or the built-in default.
153    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    /// Replaces the options from the host side, keeping a selection that is
170    /// still offered and dropping one that is not.
171    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        // The keyboard starts on what is already chosen, so the first arrow
226        // key moves from the current answer rather than from the top.
227        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    /// The next option that can actually be chosen, skipping refusals.
258    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                // One glyph in both states: the menu itself shows whether the
578                // control is open, and a flipped arrow would say it twice.
579                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}