Skip to main content

gpui_cn/select/
mod.rs

1//! A shadcn-style select: a trigger that opens a menu of items, with an
2//! optional search field (local, or answered by the application on a
3//! background thread or over the network), group labels, separators,
4//! descriptions, and a single or multiple selection, over a virtual list.
5
6mod item;
7mod menu;
8mod state;
9
10use std::rc::Rc;
11
12use gpui_kit::{
13    AnyElement, App, ElementId, Entity, Hsla, InteractiveElement as _, IntoElement, KeyBinding,
14    Length, ParentElement as _, Pixels, RenderOnce, SharedString, StatefulInteractiveElement as _,
15    StyleRefinement, Styled, Window,
16    assets::IconName,
17    base::{
18        self, Align, Disableable, ElementExt as _, GlobalState, Placement, Positioner,
19        StyledExt as _, TestSupportExt as _,
20        actions::{Confirm, SelectDown, SelectFirst, SelectLast, SelectUp},
21        h_flex,
22        input::InputContextMenuCapabilities,
23    },
24    deferred, div, point,
25    prelude::FluentBuilder as _,
26    px,
27};
28
29pub use item::{SelectEntry, SelectItem, SelectValue};
30pub use menu::SelectRow;
31pub use state::{SearchHandler, SelectEvent, SelectState};
32
33use menu::{ItemRenderer, LabelRenderer, Menu, PartRenderer, Rows};
34
35use crate::{
36    ActiveTheme as _, ButtonSize, Icon, MenuEntry, Theme, ThemeTokens,
37    button::ControlGeometry,
38    menu::{MenuLook, MenuMotion, TextMenuBuilder, corner},
39    theme::mix,
40};
41
42/// The key context of a select's trigger and menu, outside base's own.
43/// Base binds the arrows, Enter, and Escape; this adds the keys base
44/// leaves to the styled layer. Space confirms only outside the search
45/// field, where it is a character.
46const CONTEXT: &str = "GpuiCnSelect";
47
48pub(crate) fn init(cx: &mut App) {
49    cx.bind_keys([
50        KeyBinding::new(
51            "space",
52            Confirm { secondary: false },
53            Some("GpuiCnSelect && !Input"),
54        ),
55        KeyBinding::new("home", SelectFirst, Some(CONTEXT)),
56        KeyBinding::new("end", SelectLast, Some(CONTEXT)),
57    ]);
58}
59
60type ValueFormatter<V> = Rc<dyn Fn(&[SelectItem<V>]) -> SharedString>;
61type ValueRenderer<V> = Rc<dyn Fn(&[SelectItem<V>], &mut Window, &mut App) -> AnyElement>;
62
63/// A shadcn-style select on `gpui_base::Select`: a trigger that shows the
64/// selection and opens a menu of items.
65///
66/// Base owns the combobox role, Tab order, the arrows and Enter that open
67/// the menu, Escape that closes it, and the focus transfer between the
68/// trigger and the menu. This type owns the look and the menu: the
69/// trigger surface, the popover, its rows with icons, descriptions, group
70/// labels and separators, the search field, the check beside a selected
71/// row, the highlight that follows the keyboard and the pointer, and the
72/// virtual list the rows scroll in.
73///
74/// The rows and the selection live in a [`SelectState`] the view owns and
75/// observes. Both are generic over the item value, any [`SelectValue`]:
76/// a string or integer, or the application's own enum or record.
77///
78/// ```
79/// use gpui_cn::{Select, SelectItem, SelectState};
80/// use gpui_kit::{AppContext as _, Context, Entity, IntoElement, Render, Window};
81///
82/// struct Settings {
83///     shortcut: Entity<SelectState<&'static str>>,
84/// }
85///
86/// impl Settings {
87///     fn new(cx: &mut Context<Self>) -> Self {
88///         let shortcut = cx.new(|cx| {
89///             SelectState::new(
90///                 [
91///                     SelectItem::new("enter", "Enter"),
92///                     SelectItem::new("cmd-enter", "Cmd + Enter always"),
93///                 ],
94///                 cx,
95///             )
96///             .with_selected(["enter"])
97///         });
98///         cx.observe(&shortcut, |_, _, cx| cx.notify()).detach();
99///         Self { shortcut }
100///     }
101/// }
102///
103/// impl Render for Settings {
104///     fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
105///         Select::new("shortcut", &self.shortcut).accessibility_label("Send shortcut")
106///     }
107/// }
108/// ```
109///
110/// The id keys focus, motion, and the rows, so it must be stable across
111/// frames and unique under the nearest stateful ancestor.
112#[derive(IntoElement)]
113pub struct Select<V: SelectValue> {
114    id: ElementId,
115    state: Entity<SelectState<V>>,
116    style: StyleRefinement,
117    size: ButtonSize,
118    placeholder: Option<SharedString>,
119    empty_text: SharedString,
120    accessibility_label: Option<SharedString>,
121    icon: Option<Icon>,
122    menu_width: Option<Length>,
123    align: Align,
124    format_value: Option<ValueFormatter<V>>,
125    render_value: Option<ValueRenderer<V>>,
126    render_item: Option<ItemRenderer<V>>,
127    render_label: Option<LabelRenderer>,
128    render_empty: Option<PartRenderer>,
129    render_loading: Option<PartRenderer>,
130    search_context_menu: Option<TextMenuBuilder>,
131    search_context_menu_enabled: bool,
132    disabled: bool,
133    tab_index: isize,
134    ghost: bool,
135    chevron: bool,
136}
137
138impl<V: SelectValue> Select<V> {
139    /// A select over `state`, with a stable id.
140    pub fn new(id: impl Into<ElementId>, state: &Entity<SelectState<V>>) -> Self {
141        Self {
142            id: id.into(),
143            state: state.clone(),
144            style: StyleRefinement::default(),
145            size: ButtonSize::Default,
146            placeholder: None,
147            empty_text: "No results".into(),
148            accessibility_label: None,
149            icon: None,
150            menu_width: None,
151            align: Align::End,
152            format_value: None,
153            render_value: None,
154            render_item: None,
155            render_label: None,
156            render_empty: None,
157            render_loading: None,
158            search_context_menu: None,
159            search_context_menu_enabled: true,
160            disabled: false,
161            tab_index: 0,
162            ghost: false,
163            chevron: true,
164        }
165    }
166
167    /// A quiet trigger for a toolbar: no fill and no hairline at rest, and
168    /// the surface of a ghost button while the pointer is on it or the menu
169    /// is open. The menu is the same.
170    pub fn ghost(mut self) -> Self {
171        self.ghost = true;
172        self
173    }
174
175    /// Whether the trigger shows its chevron. On by default. A trigger
176    /// that shows only an icon (see [`render_value`](Self::render_value))
177    /// turns it off.
178    pub fn chevron(mut self, chevron: bool) -> Self {
179        self.chevron = chevron;
180        self
181    }
182
183    /// The trigger's size tier, shared with [`Button`](crate::Button).
184    pub fn size(mut self, size: ButtonSize) -> Self {
185        self.size = size;
186        self
187    }
188
189    /// The text the trigger shows while nothing is selected, in the muted
190    /// color.
191    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
192        self.placeholder = Some(placeholder.into());
193        self
194    }
195
196    /// The text the menu shows when the search matches no row, or while
197    /// a search handler is still answering with no rows to show. The
198    /// default is "No results".
199    pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
200        self.empty_text = text.into();
201        self
202    }
203
204    /// The name a screen reader announces for the control. The selection
205    /// is its value, not its name, so every select should have one.
206    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
207        self.accessibility_label = Some(label.into());
208        self
209    }
210
211    /// An icon before the trigger's text, in place of the selected item's
212    /// own.
213    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
214        self.icon = Some(icon.into());
215        self
216    }
217
218    /// A fixed menu width. Without one the menu is as wide as its rows,
219    /// never narrower than the trigger, and no wider than the theme's
220    /// menu width. A menu with many rows should set one: the virtual list
221    /// measures only the rows in view.
222    pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
223        self.menu_width = Some(width.into());
224        self
225    }
226
227    /// Which edge of the trigger the menu lines up with. The default is
228    /// `End`, as the reference app opens its menus.
229    pub fn align(mut self, align: Align) -> Self {
230        self.align = align;
231        self
232    }
233
234    /// Builds the trigger's text from the selected items. The default is
235    /// the item's label, "N selected" for more than one, and the
236    /// placeholder for none.
237    pub fn format_value(
238        mut self,
239        format: impl Fn(&[SelectItem<V>]) -> SharedString + 'static,
240    ) -> Self {
241        self.format_value = Some(Rc::new(format));
242        self
243    }
244
245    /// Draws the inside of every item row: `render` gets the item and
246    /// the row's state and returns what goes between the row's padding, in
247    /// place of the icon, label, description, and check. The row itself
248    /// (its height, highlight, hover, and click) stays the menu's, so a
249    /// custom row still behaves like the others. The trigger keeps
250    /// showing the label; see [`format_value`](Self::format_value).
251    ///
252    /// ```
253    /// use gpui_cn::{Select, SelectItem, SelectState};
254    /// use gpui_kit::{Entity, IntoElement, ParentElement as _, Styled as _, div};
255    ///
256    /// fn assignee(state: &Entity<SelectState<usize>>) -> Select<usize> {
257    ///     Select::new("assignee", state).render_item(|item, row, _, _| {
258    ///         div()
259    ///             .flex()
260    ///             .gap_2()
261    ///             .child(item.label().clone())
262    ///             .when_some(item.description_text().cloned(), |this, email| this.child(email))
263    ///             .when(row.is_selected(), |this| this.child("*"))
264    ///     })
265    /// }
266    /// # use gpui_kit::prelude::FluentBuilder as _;
267    /// ```
268    pub fn render_item<E: IntoElement>(
269        mut self,
270        render: impl Fn(&SelectItem<V>, SelectRow, &mut Window, &mut App) -> E + 'static,
271    ) -> Self {
272        self.render_item = Some(Rc::new(move |item, row, window, cx| {
273            render(item, row, window, cx).into_any_element()
274        }));
275        self
276    }
277
278    /// Draws the trigger's content from the selected items, in place of
279    /// the icon and text; the chevron stays. For a text that differs, see
280    /// [`format_value`](Self::format_value).
281    pub fn render_value<E: IntoElement>(
282        mut self,
283        render: impl Fn(&[SelectItem<V>], &mut Window, &mut App) -> E + 'static,
284    ) -> Self {
285        self.render_value = Some(Rc::new(move |items, window, cx| {
286            render(items, window, cx).into_any_element()
287        }));
288        self
289    }
290
291    /// Draws a group heading from its text, in place of the muted row.
292    pub fn render_label<E: IntoElement>(
293        mut self,
294        render: impl Fn(&SharedString, &mut Window, &mut App) -> E + 'static,
295    ) -> Self {
296        self.render_label = Some(Rc::new(move |text, window, cx| {
297            render(text, window, cx).into_any_element()
298        }));
299        self
300    }
301
302    /// Draws the menu's body when no row matches, in place of
303    /// [`empty_text`](Self::empty_text): an illustration, a hint, an
304    /// action that adds what was typed.
305    pub fn render_empty<E: IntoElement>(
306        mut self,
307        render: impl Fn(&mut Window, &mut App) -> E + 'static,
308    ) -> Self {
309        self.render_empty = Some(Rc::new(move |window, cx| {
310            render(window, cx).into_any_element()
311        }));
312        self
313    }
314
315    /// Draws the menu's body while a search handler is still answering,
316    /// in place of the rows: skeleton rows the height of the ones to
317    /// come keep the menu's size, so the answer fills it in. Without it
318    /// the last rows stay until the answer.
319    pub fn render_loading<E: IntoElement>(
320        mut self,
321        render: impl Fn(&mut Window, &mut App) -> E + 'static,
322    ) -> Self {
323        self.render_loading = Some(Rc::new(move |window, cx| {
324            render(window, cx).into_any_element()
325        }));
326        self
327    }
328
329    /// Shapes the menu a right click opens on the search field, as
330    /// [`Field::context_menu`](crate::Field::context_menu) does for a field:
331    /// `build` gets the default rows (Cut, Copy, Paste, a separator, Select
332    /// All) and returns the rows to show, the defaults extended or
333    /// replaced. No rows, no menu.
334    pub fn search_context_menu(
335        mut self,
336        build: impl Fn(
337            Vec<MenuEntry>,
338            InputContextMenuCapabilities,
339            &mut Window,
340            &mut App,
341        ) -> Vec<MenuEntry>
342        + 'static,
343    ) -> Self {
344        self.search_context_menu = Some(Rc::new(build));
345        self
346    }
347
348    /// Whether a right click on the search field opens its menu. On by
349    /// default.
350    pub fn search_context_menu_enabled(mut self, enabled: bool) -> Self {
351        self.search_context_menu_enabled = enabled;
352        self
353    }
354
355    /// The focus traversal index. The default is `0`. Tab always reaches
356    /// an enabled trigger; base owns that.
357    pub fn tab_index(mut self, tab_index: isize) -> Self {
358        self.tab_index = tab_index;
359        self
360    }
361}
362
363impl<V: SelectValue> Disableable for Select<V> {
364    fn disabled(mut self, disabled: bool) -> Self {
365        self.disabled = disabled;
366        self
367    }
368}
369
370impl<V: SelectValue> Styled for Select<V> {
371    fn style(&mut self) -> &mut StyleRefinement {
372        &mut self.style
373    }
374}
375
376/// Everything the trigger reads from the theme, in one borrow.
377struct TriggerLook {
378    fill: Hsla,
379    fill_hovered: Hsla,
380    ghost_hovered: Hsla,
381    ghost_open: Hsla,
382    border: Hsla,
383    foreground: Hsla,
384    placeholder: Hsla,
385    indicator: Hsla,
386    ring: Hsla,
387    ring_spread: Pixels,
388    geometry: ControlGeometry,
389    disabled_opacity: f32,
390    touch: bool,
391}
392
393impl TriggerLook {
394    fn of(size: ButtonSize, theme: &ThemeTokens) -> Self {
395        Self {
396            fill: theme.select_trigger,
397            fill_hovered: mix(theme.select_trigger, theme.popover_foreground, 0.05),
398            ghost_hovered: theme.secondary(),
399            ghost_open: theme.selected,
400            border: theme.select_trigger_border,
401            foreground: theme.popover_foreground,
402            placeholder: theme.muted_foreground(),
403            indicator: theme.select_indicator,
404            ring: theme.focus_ring(),
405            ring_spread: theme.metrics.focus_ring,
406            geometry: size.geometry(theme),
407            disabled_opacity: theme.disabled_opacity,
408            touch: theme.touch,
409        }
410    }
411}
412
413/// What the trigger shows for the selection.
414struct Value {
415    text: SharedString,
416    leading: Option<AnyElement>,
417    is_placeholder: bool,
418}
419
420impl<V: SelectValue> Select<V> {
421    /// The trigger's text and leading element: the selection, else the
422    /// placeholder.
423    fn value(&self, window: &mut Window, cx: &mut App) -> Value {
424        let items: Vec<SelectItem<V>> = self.state.read(cx).selected_items().to_vec();
425        let text = match &self.format_value {
426            Some(format) => Some(format(&items)),
427            None => match items.as_slice() {
428                [] => None,
429                [item] => Some(item.label().clone()),
430                many => Some(format!("{} selected", many.len()).into()),
431            },
432        };
433        let leading = match (&self.icon, items.as_slice()) {
434            (Some(icon), _) => Some(icon.clone().into_any_element()),
435            (None, [item]) => item.render_leading(window, cx),
436            _ => None,
437        };
438        match text {
439            Some(text) => Value {
440                text,
441                leading,
442                is_placeholder: false,
443            },
444            None => Value {
445                text: self.placeholder.clone().unwrap_or_default(),
446                leading,
447                is_placeholder: true,
448            },
449        }
450    }
451
452    fn trigger(
453        &self,
454        look: &TriggerLook,
455        value: Value,
456        open: bool,
457        pointer_cursors: bool,
458        window: &mut Window,
459        cx: &mut App,
460    ) -> AnyElement {
461        let state = &self.state;
462        let interactive = !self.disabled;
463        let hovered = window.use_keyed_state(
464            ElementId::NamedChild(self.id.clone().into(), "hovered".into()),
465            cx,
466            |_, _| false,
467        );
468        let is_hovered = *hovered.read(cx) && interactive && !look.touch;
469        let focus_visible =
470            state.read(cx).trigger_focus().is_focused(window) && window.last_input_was_keyboard();
471        let mut fill = match (self.ghost, open, is_hovered) {
472            (true, true, _) => look.ghost_open,
473            (true, false, true) => look.ghost_hovered,
474            (true, false, false) => gpui_kit::transparent_black(),
475            (false, false, false) => look.fill,
476            (false, _, _) => look.fill_hovered,
477        };
478        let mut border = if self.ghost {
479            gpui_kit::transparent_black()
480        } else {
481            look.border
482        };
483        let mut foreground = if value.is_placeholder {
484            look.placeholder
485        } else {
486            look.foreground
487        };
488        if self.disabled {
489            fill = fill.opacity(look.disabled_opacity);
490            border = border.opacity(look.disabled_opacity);
491            foreground = look.placeholder;
492        }
493        let trigger_bounds = state.read(cx).trigger_bounds().clone();
494        h_flex()
495            .id(ElementId::NamedChild(
496                self.id.clone().into(),
497                "trigger".into(),
498            ))
499            .test_support()
500            .flex_shrink_0()
501            .items_center()
502            .gap_1p5()
503            .h(look.geometry.height)
504            .px(look.geometry.padding)
505            .rounded(look.geometry.radius)
506            .bg(fill)
507            .border_1()
508            .border_color(border)
509            .text_size(look.geometry.text_size)
510            .text_color(foreground)
511            .whitespace_nowrap()
512            .relative()
513            .when(focus_visible, |this| {
514                this.shadow(vec![gpui_kit::BoxShadow {
515                    color: look.ring,
516                    offset: point(px(0.), px(0.)),
517                    blur_radius: px(0.),
518                    spread_radius: look.ring_spread,
519                    inset: false,
520                }])
521            })
522            .map(|this| {
523                if interactive && pointer_cursors {
524                    this.cursor_pointer()
525                } else {
526                    this.cursor_default()
527                }
528            })
529            .map(|this| match &self.render_value {
530                Some(render) => {
531                    let items: Vec<SelectItem<V>> = state.read(cx).selected_items().to_vec();
532                    this.child(div().min_w_0().child(render(&items, window, cx)))
533                }
534                None => this
535                    .when_some(value.leading, |this, leading| {
536                        this.child(div().flex_shrink_0().child(leading))
537                    })
538                    .child(
539                        div()
540                            .min_w_0()
541                            .overflow_hidden()
542                            .text_ellipsis()
543                            .child(value.text),
544                    ),
545            })
546            .when(self.chevron, |this| {
547                // At the trailing edge when the trigger is wider than its
548                // value, as shadcn's `justify-between` trigger puts it.
549                this.child(
550                    div().ml_auto().flex_none().child(
551                        Icon::from(IconName::ChevronDown)
552                            .size_4()
553                            .text_color(look.indicator),
554                    ),
555                )
556            })
557            // The recorder spans the padding box, so the hairline around
558            // the trigger is added back.
559            .on_prepaint(move |bounds, _, _| trigger_bounds.set(bounds.dilate(px(1.))))
560            .on_hover({
561                let hovered = hovered.clone();
562                move |is_hovered, _, cx| {
563                    hovered.update(cx, |state, cx| {
564                        if *state != *is_hovered {
565                            *state = *is_hovered;
566                            cx.notify();
567                        }
568                    });
569                }
570            })
571            .when(interactive, |this| {
572                let state = state.clone();
573                this.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
574                    // The press is the trigger's: it must not move focus
575                    // with the pointer, start the window text selection, or
576                    // reach what is behind the trigger.
577                    window.prevent_default();
578                    cx.stop_propagation();
579                    GlobalState::suppress_text_selection(cx);
580                    state.update(cx, |state, cx| {
581                        // A press on the trigger while the menu is open
582                        // reaches the menu's outside-press first, which
583                        // closes it; that press must not open it again.
584                        if state.is_open() != open {
585                            return;
586                        }
587                        if open {
588                            state.close(window, cx);
589                        } else {
590                            state.open(window, cx);
591                        }
592                    });
593                })
594            })
595            .into_any_element()
596    }
597
598    /// The menu, deferred over the page, while it is open or coming in:
599    /// it fades up while it slides the last step down from the trigger,
600    /// and goes out in one frame.
601    fn menu(
602        &self,
603        look: &MenuLook,
604        open: bool,
605        pointer_cursors: bool,
606        window: &mut Window,
607        cx: &mut App,
608    ) -> Option<AnyElement> {
609        let motion = MenuMotion::sample(
610            ElementId::NamedChild(self.id.clone().into(), "presence".into()),
611            open,
612            true,
613            look,
614            window,
615            cx,
616        )?;
617        let state = &self.state;
618        let (bounds, placement, content_focus) = {
619            let state = state.read(cx);
620            (
621                state.trigger_bounds().get(),
622                state.menu_placement().unwrap_or(Placement::Bottom),
623                state.content_focus().clone(),
624            )
625        };
626        let menu = Menu {
627            rows: Rows {
628                id: self.id.clone(),
629                state: state.clone(),
630                look: look.clone(),
631                render_item: self.render_item.clone(),
632                render_label: self.render_label.clone(),
633                pointer_cursors,
634            },
635            content_focus,
636            width: self.menu_width,
637            trigger_width: bounds.size.width,
638            empty_text: self.empty_text.clone(),
639            render_empty: self.render_empty.clone(),
640            render_loading: self.render_loading.clone(),
641            search_menu_builder: self.search_context_menu.clone(),
642            search_menu_enabled: self.search_context_menu_enabled,
643            motion,
644        }
645        .render(window, cx);
646        // A corner anchor clamps into the window but never flips, so the
647        // side the state chose when the menu opened holds while a search
648        // shortens the rows.
649        let (anchor, position) = corner(placement, self.align, bounds, look.gap);
650        Some(
651            deferred(Positioner::corner(anchor, position).occlude().child(menu))
652                .with_priority(base::POPUP_PRIORITY)
653                .into_any_element(),
654        )
655    }
656}
657
658impl<V: SelectValue> RenderOnce for Select<V> {
659    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
660        let pointer_cursors = Theme::global(cx).pointer_cursors;
661        let (trigger_look, menu_look) = {
662            let theme = cx.theme();
663            (
664                TriggerLook::of(self.size, theme),
665                MenuLook::of(theme, window.rem_size()),
666            )
667        };
668        let state = self.state.clone();
669        let (open, trigger_focus, content_focus) = {
670            let state = state.read(cx);
671            (
672                state.is_open(),
673                state.trigger_focus().clone(),
674                state.content_focus_handle(cx),
675            )
676        };
677        let interactive = !self.disabled;
678
679        let value = self.value(window, cx);
680        let accessibility_value = (!value.is_placeholder).then(|| value.text.clone());
681        let trigger = self.trigger(&trigger_look, value, open, pointer_cursors, window, cx);
682        let menu = self.menu(&menu_look, open, pointer_cursors, window, cx);
683
684        // The handle's tab index is window state, so setting it on a clone
685        // sets it for the trigger.
686        let trigger_focus = trigger_focus.tab_index(self.tab_index);
687        let root = base::Select::new(self.id.clone())
688            .open(open)
689            .disabled(self.disabled)
690            .focus_handle(&trigger_focus)
691            .content_focus_handle(&content_focus)
692            .when_some(self.accessibility_label, |this, label| {
693                this.accessibility_label(label)
694            })
695            .when_some(accessibility_value, |this, value| {
696                this.accessibility_value(value)
697            })
698            .on_open_change({
699                let state = state.clone();
700                move |next, window, cx| {
701                    state.update(cx, |state, cx| {
702                        if next {
703                            state.open(window, cx);
704                        } else {
705                            state.close(window, cx);
706                        }
707                    });
708                }
709            })
710            .on_confirm({
711                let state = state.clone();
712                move |window, cx| {
713                    state.update(cx, |state, cx| state.choose_highlighted(window, cx));
714                    // Base moves focus into the menu after a confirm, for a
715                    // menu that stays open. One that closed on it wants the
716                    // trigger back, once base is done.
717                    let state = state.clone();
718                    window.defer(cx, move |window, cx| {
719                        let (closed, trigger_focus) = {
720                            let state = state.read(cx);
721                            (!state.is_open(), state.trigger_focus().clone())
722                        };
723                        if closed {
724                            trigger_focus.focus(window, cx);
725                        }
726                    });
727                }
728            })
729            .child(trigger)
730            .children(menu);
731
732        // Base opens the menu on the arrows and moves focus into it; once
733        // it is open the arrows walk the rows. `open` is the state at
734        // render, so the press that opens the menu does not also move.
735        div()
736            .id(ElementId::NamedChild(self.id.clone().into(), "root".into()))
737            .key_context(CONTEXT)
738            .flex_shrink_0()
739            .refine_style(&self.style)
740            .when(interactive && open, |this| {
741                this.on_action({
742                    let state = state.clone();
743                    move |_: &SelectUp, _, cx| {
744                        state.update(cx, |state, cx| state.move_highlight(-1, cx));
745                    }
746                })
747                .on_action({
748                    let state = state.clone();
749                    move |_: &SelectDown, _, cx| {
750                        state.update(cx, |state, cx| state.move_highlight(1, cx));
751                    }
752                })
753                .on_action({
754                    let state = state.clone();
755                    move |_: &SelectFirst, _, cx| {
756                        state.update(cx, |state, cx| state.highlight_first(cx));
757                    }
758                })
759                .on_action({
760                    let state = state.clone();
761                    move |_: &SelectLast, _, cx| {
762                        state.update(cx, |state, cx| state.highlight_last(cx));
763                    }
764                })
765            })
766            .child(root)
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::theme::{ThemeConfig, to_hex};
774    use gpui_kit::base::ThemeAppearance;
775
776    #[test]
777    fn the_trigger_reads_the_reference_values() {
778        let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
779        let trigger = TriggerLook::of(ButtonSize::Default, &theme);
780        assert_eq!(to_hex(trigger.fill), "#292929");
781        assert_eq!(to_hex(trigger.border), "#3a3a3a");
782        assert_eq!(to_hex(trigger.foreground), "#ffffff");
783        assert_eq!(trigger.geometry.height, px(28.));
784        assert!(trigger.fill_hovered.l > trigger.fill.l);
785    }
786}