Skip to main content

herogpui_components/
dropdown.rs

1//! Dropdown & Menu — port of `@heroui/dropdown`, `@heroui/menu` and
2//! `@heroui/listbox`.
3
4use gpui::{
5    prelude::FluentBuilder as _, px, AnyElement, App, Bounds, ClickEvent, InteractiveElement,
6    IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
7    Styled, Window,
8};
9use herogpui_core::{element_id, SelectionMode};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::{self, A11y as _};
13use crate::icons;
14
15/// One entry of a dropdown menu.
16pub enum MenuItem {
17    /// Section caption (`<MenuSection>` title).
18    SectionLabel(SharedString),
19    /// A divider row between items.
20    Separator,
21    /// A menu row.
22    ///
23    /// `#[non_exhaustive]`: build one through [`MenuItem::new`] and its
24    /// builders, which cover every field, so a later row capability is an
25    /// additive change rather than a break for literal construction.
26    #[non_exhaustive]
27    Item {
28        /// Stable key identifying the item.
29        key: SharedString,
30        /// Visible text of the item.
31        label: SharedString,
32        /// Optional shortcut hint shown on the row.
33        shortcut: Option<SharedString>,
34        /// Optional icon asset path.
35        icon: Option<&'static str>,
36        /// Whether the item uses danger styling.
37        is_danger: bool,
38        /// `Description` inside a `Dropdown.Item` — v3's "With Descriptions".
39        description: Option<SharedString>,
40        /// `Dropdown.SubmenuTrigger` — the rows this item opens. The row grows a
41        /// trailing indicator and the panel appears beside it.
42        submenu: Vec<MenuItem>,
43        /// Whether the row's own content owns the interaction, in place of the
44        /// row acting as one button. See [`MenuItem::is_interactive`].
45        is_interactive: bool,
46    },
47}
48
49impl MenuItem {
50    /// Creates a menu item from a key and a label.
51    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52        MenuItem::Item {
53            key: key.into(),
54            label: label.into(),
55            shortcut: None,
56            icon: None,
57            is_danger: false,
58            description: None,
59            submenu: Vec::new(),
60            is_interactive: false,
61        }
62    }
63
64    /// `Description` — the second line v3 composes inside an item.
65    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
66        if let MenuItem::Item { description, .. } = &mut self {
67            *description = Some(text.into());
68        }
69        self
70    }
71
72    /// Hands the row's interaction to the element `Menu::item_content` draws
73    /// for it, in place of the row behaving as one button.
74    ///
75    /// HeroUI composes rows that carry an inline secondary control — a small
76    /// select, a stepper — on the trailing edge, where pressing that control
77    /// must not also pick the row and close the menu. A stock row attaches an
78    /// unconditional click that reports the action and dismisses, plus the
79    /// `[data-pressed]` 98% scale over the whole row, so a hosted control is
80    /// unusable inside one. Setting this drops all three, and Enter and Space
81    /// stop activating the row, leaving the hosted element to answer the
82    /// pointer and the keyboard itself.
83    ///
84    /// What it keeps: the row stays a keyboard stop with its hover fill,
85    /// highlight and focus ring, and it keeps its `menuitem` role and
86    /// accessible name, so arrowing through the menu is unchanged.
87    ///
88    /// Nothing else is needed to host a popup-opening control such as a
89    /// [`crate::select::Select`]. Such a control registers its own panel on the
90    /// shared overlay stack, which makes it topmost, and the menu's
91    /// outside-press and Escape dismissals are already gated on being topmost —
92    /// so they stand down for as long as the inner panel is open.
93    ///
94    /// Ignored together with [`MenuItem::submenu`]: a submenu trigger already
95    /// opts out of the row click, and its flyout is the interaction.
96    pub fn is_interactive(mut self, interactive: bool) -> Self {
97        if let MenuItem::Item { is_interactive, .. } = &mut self {
98            *is_interactive = interactive;
99        }
100        self
101    }
102
103    /// `Dropdown.SubmenuTrigger` — the rows this item opens.
104    pub fn submenu(mut self, items: Vec<MenuItem>) -> Self {
105        if let MenuItem::Item { submenu, .. } = &mut self {
106            *submenu = items;
107        }
108        self
109    }
110
111    /// Sets the shortcut hint shown on the row.
112    pub fn shortcut(mut self, s: impl Into<SharedString>) -> Self {
113        if let MenuItem::Item { shortcut, .. } = &mut self {
114            *shortcut = Some(s.into());
115        }
116        self
117    }
118
119    /// Sets the icon asset path.
120    pub fn icon(mut self, path: &'static str) -> Self {
121        if let MenuItem::Item { icon, .. } = &mut self {
122            *icon = Some(path);
123        }
124        self
125    }
126
127    /// Marks the item as a danger item.
128    pub fn danger(mut self) -> Self {
129        if let MenuItem::Item { is_danger, .. } = &mut self {
130            *is_danger = true;
131        }
132        self
133    }
134}
135
136type OnSelect = std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
137
138/// Menu panel (`<Menu>` / `<Listbox>`).
139/// `type` on `Dropdown.ItemIndicator` — how a selected item is marked.
140#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
141pub enum IndicatorKind {
142    /// A checkmark indicator.
143    #[default]
144    Checkmark,
145    /// A dot indicator.
146    Dot,
147}
148
149impl IndicatorKind {
150    /// Every indicator kind, in display order.
151    pub const ALL: [IndicatorKind; 2] = [IndicatorKind::Checkmark, IndicatorKind::Dot];
152
153    /// The display name of this indicator kind.
154    pub fn label(self) -> &'static str {
155        match self {
156            IndicatorKind::Checkmark => "Checkmark",
157            IndicatorKind::Dot => "Dot",
158        }
159    }
160}
161
162/// `onSelectionChange` — the whole selection after an item is activated.
163pub type OnSelectionChange =
164    std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
165
166type ItemContent =
167    std::sync::Arc<dyn Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static>;
168type ItemIndicatorContent =
169    std::sync::Arc<dyn Fn(&SharedString, bool, bool) -> AnyElement + 'static>;
170type ItemStartContent = std::sync::Arc<
171    dyn Fn(&SharedString, crate::util::InteractiveState) -> Option<AnyElement> + 'static,
172>;
173type OnDismiss = std::rc::Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
174type PanelBounds = std::rc::Rc<std::cell::RefCell<Vec<Bounds<Pixels>>>>;
175
176/// A menu panel listing `MenuItem` rows.
177#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
178#[derive(IntoElement)]
179pub struct Menu {
180    /// Set by `Dropdown` while the menu is playing its `[data-exiting]` run.
181    exiting: bool,
182    /// Submenus are already inside their parent's deferred draw and cannot
183    /// defer a second time.
184    deferred: bool,
185    panel_bounds: Option<PanelBounds>,
186    focus_first: Option<gpui::Entity<bool>>,
187    on_back: Option<std::sync::Arc<dyn Fn(&mut Window, &mut App) + 'static>>,
188    /// `children` on `Dropdown.Item` — v3's render prop, handed the item's
189    /// key, selection, focus, disabled and pressed state.
190    item_content: Option<ItemContent>,
191    /// `children` on `Dropdown.ItemIndicator` — handed the item's key,
192    /// `isSelected` and `isIndeterminate`.
193    indicator_content: Option<ItemIndicatorContent>,
194    /// The leading element a row composes before its label, in place of
195    /// the item's icon asset.
196    item_start_content: Option<ItemStartContent>,
197    id: gpui::ElementId,
198    items: Vec<MenuItem>,
199    selected_key: Option<SharedString>,
200    selection_mode: SelectionMode,
201    selected_keys: Vec<SharedString>,
202    default_selected_keys: Vec<SharedString>,
203    selection_is_controlled: bool,
204    disallow_empty_selection: bool,
205    disabled_keys: Vec<SharedString>,
206    indicator: IndicatorKind,
207    on_selection_change: Option<OnSelectionChange>,
208    on_action: Option<OnSelect>,
209    /// The fill a hovered menu row takes, in place of `--default`.
210    row_hover_bg: Option<gpui::Hsla>,
211    row_hover_foreground: Option<gpui::Hsla>,
212    /// The panel's corner radius, in place of the owning `container_radius`
213    /// helper. [`Dropdown`] forwards its own override here.
214    radius: Option<Pixels>,
215    panel_min_width: Option<Pixels>,
216    panel_max_width: Option<Pixels>,
217    panel_max_height: Option<Pixels>,
218    row_height: Option<Pixels>,
219    row_padding_x: Option<Pixels>,
220    row_padding_y: Option<Pixels>,
221    row_text_size: Option<Pixels>,
222    row_gap: Option<Pixels>,
223    panel_padding: Option<Pixels>,
224    panel_gap: Option<Pixels>,
225    separator_inset: Option<Pixels>,
226    separator_thickness: Option<Pixels>,
227    animate_entry: bool,
228    animate_entry_is_set: bool,
229    focus_handle: Option<gpui::FocusHandle>,
230    /// Set by `Dropdown`: the menu panel is where Escape and an outside press
231    /// land, and the open state belongs to the wrapper. The `bool` says
232    /// whether the trigger should take the focus back: Escape, an outside
233    /// press and a mouse pick can, because no key-up follows them; an Enter
234    /// pick cannot, because gpui activates a focused element on key up and the
235    /// trigger's click listener would reopen the menu it just closed.
236    on_dismiss: Option<OnDismiss>,
237    overlay_token: Option<crate::util::OverlayToken>,
238    dropdown_composition: bool,
239    /// Test-only label for the panel, read with `debug_bounds`.
240    ///
241    /// Not a v3 prop: naming the laid-out panel beats wrapping it, because a
242    /// wrapper between the positioner and the menu would measure and cap
243    /// while the real panel kept its natural size underneath.
244    panel_debug_label: Option<&'static str>,
245    /// The `sx` slot, refined over the root style at the end of render.
246    sx: Option<Box<gpui::StyleRefinement>>,
247    recipes: Vec<SharedString>,
248}
249
250impl Menu {
251    /// Creates a menu from an element id and its items.
252    pub fn new(id: impl Into<gpui::ElementId>, items: Vec<MenuItem>) -> Self {
253        Self {
254            exiting: false,
255            deferred: true,
256            panel_bounds: None,
257            focus_first: None,
258            on_back: None,
259            item_content: None,
260            indicator_content: None,
261            item_start_content: None,
262            id: id.into(),
263            items,
264            selected_key: None,
265            selection_mode: SelectionMode::None,
266            selected_keys: Vec::new(),
267            default_selected_keys: Vec::new(),
268            selection_is_controlled: false,
269            disallow_empty_selection: false,
270            disabled_keys: Vec::new(),
271            indicator: IndicatorKind::default(),
272            on_selection_change: None,
273            on_action: None,
274            row_hover_bg: None,
275            row_hover_foreground: None,
276            radius: None,
277            panel_min_width: None,
278            panel_max_width: None,
279            panel_max_height: None,
280            row_height: None,
281            row_padding_x: None,
282            row_padding_y: None,
283            row_text_size: None,
284            row_gap: None,
285            panel_padding: None,
286            panel_gap: None,
287            separator_inset: None,
288            separator_thickness: None,
289            animate_entry: true,
290            animate_entry_is_set: false,
291            focus_handle: None,
292            on_dismiss: None,
293            overlay_token: None,
294            dropdown_composition: false,
295            panel_debug_label: None,
296            sx: None,
297            recipes: Vec::new(),
298        }
299    }
300
301    /// What to run when the menu closes: an item activation, Escape, or a
302    /// press outside the panel.
303    ///
304    /// Not a v3 prop: v3's `Dropdown.Menu` is inside the `Dropdown` that owns
305    /// `isOpen`, and React Aria's `useOverlay` closes it from there. Standalone
306    /// callers remove the menu in this callback. The `bool` is whether to return
307    /// the focus to the trigger — see the field docs for why a key pick passes
308    /// `false`.
309    pub fn on_dismiss(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
310        self.on_dismiss = Some(std::rc::Rc::new(f));
311        self
312    }
313
314    /// Overrides the panel min width in pixels, including submenus.
315    /// Unset preserves the stock metric.
316    pub fn panel_min_width(mut self, value: impl Into<Pixels>) -> Self {
317        self.panel_min_width = Some(value.into());
318        self
319    }
320
321    /// Overrides the panel max width in pixels, including submenus.
322    /// Unset preserves the stock metric.
323    pub fn panel_max_width(mut self, value: impl Into<Pixels>) -> Self {
324        self.panel_max_width = Some(value.into());
325        self
326    }
327
328    /// Overrides the panel max height in pixels, including submenus.
329    /// Unset preserves the stock metric.
330    pub fn panel_max_height(mut self, value: impl Into<Pixels>) -> Self {
331        self.panel_max_height = Some(value.into());
332        self
333    }
334
335    /// Overrides the row minimum height in pixels, including submenus.
336    /// Described rows may grow to fit their content.
337    /// Unset preserves the stock metric.
338    pub fn row_height(mut self, value: impl Into<Pixels>) -> Self {
339        self.row_height = Some(value.into());
340        self
341    }
342
343    /// Overrides the row padding x in pixels, including submenus.
344    /// Unset preserves the stock metric.
345    pub fn row_padding_x(mut self, value: impl Into<Pixels>) -> Self {
346        self.row_padding_x = Some(value.into());
347        self
348    }
349
350    /// Overrides the row padding y in pixels, including submenus.
351    /// Unset preserves the stock metric.
352    pub fn row_padding_y(mut self, value: impl Into<Pixels>) -> Self {
353        self.row_padding_y = Some(value.into());
354        self
355    }
356
357    /// Overrides the row text size in pixels, including submenus.
358    /// Unset preserves the stock metric.
359    pub fn row_text_size(mut self, value: impl Into<Pixels>) -> Self {
360        self.row_text_size = Some(value.into());
361        self
362    }
363
364    /// Overrides the row gap in pixels, including submenus.
365    /// Unset preserves the stock metric.
366    pub fn row_gap(mut self, value: impl Into<Pixels>) -> Self {
367        self.row_gap = Some(value.into());
368        self
369    }
370
371    /// Overrides the panel padding in pixels, including submenus.
372    /// Unset preserves the stock metric.
373    pub fn panel_padding(mut self, value: impl Into<Pixels>) -> Self {
374        self.panel_padding = Some(value.into());
375        self
376    }
377
378    /// Space between panel entries (default 2px), including submenus.
379    /// `row_gap` independently controls spacing inside each item.
380    pub fn panel_gap(mut self, gap: impl Into<Pixels>) -> Self {
381        self.panel_gap = Some(gap.into());
382        self
383    }
384
385    /// An absolute horizontal inset on each edge of a
386    /// [`MenuItem::Separator`], including submenus.
387    ///
388    /// Unset, the rule takes v3's own `ms-[3%] w-[94%]`: a proportional inset,
389    /// centred in the panel's content box. Set, that is replaced by the same
390    /// number of pixels on each edge whatever the panel's width — which is what
391    /// a panel sitting beside a platform menu needs, AppKit's own separator
392    /// being inset 15pt on each side inside wider panel bounds.
393    pub fn separator_inset(mut self, inset: impl Into<Pixels>) -> Self {
394        self.separator_inset = Some(inset.into());
395        self
396    }
397
398    /// Thickness of a [`MenuItem::Separator`], including submenus. Unset keeps
399    /// the theme's hairline border width.
400    pub fn separator_thickness(mut self, thickness: impl Into<Pixels>) -> Self {
401        self.separator_thickness = Some(thickness.into());
402        self
403    }
404
405    /// Whether the panel and its submenus play their entry animation (default true).
406    /// Exit animation and keyboard behavior are unchanged.
407    pub fn animate_entry(mut self, animate: bool) -> Self {
408        self.animate_entry = animate;
409        self.animate_entry_is_set = true;
410        self
411    }
412
413    /// Named theme overlay from [`herogpui_theme::ComponentThemes::menu`].
414    /// Stackable; a missing name adds no override.
415    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
416        self.recipes.push(name.into());
417        self
418    }
419
420    /// Supplies the root menu's focus handle. Submenus own independent handles
421    /// and return focus here on Left/Escape. The menu still focuses on entry.
422    pub fn focus_handle(mut self, handle: gpui::FocusHandle) -> Self {
423        self.focus_handle = Some(handle);
424        self
425    }
426
427    pub(crate) fn overlay_token(mut self, token: crate::util::OverlayToken) -> Self {
428        self.overlay_token = Some(token);
429        self
430    }
431
432    pub(crate) fn dropdown_composition(mut self) -> Self {
433        self.dropdown_composition = true;
434        self
435    }
436
437    /// Labels the panel for behavior tests (`debug_bounds`).
438    ///
439    /// Not a v3 prop: see the field docs for why tests name the panel instead
440    /// of wrapping it.
441    pub(crate) fn panel_debug_label(mut self, label: &'static str) -> Self {
442        self.panel_debug_label = Some(label);
443        self
444    }
445
446    /// The element id every piece of this menu's state is keyed by.
447    ///
448    /// Not a v3 prop -- gpui needs an explicit id on a stateful element, and
449    /// two menus that share one key share their focus, their cursor and their
450    /// typeahead.
451    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
452        self.id = id.into();
453        self
454    }
455
456    /// Plays the menu's exit instead of its entry.
457    ///
458    /// Not a v3 prop: v3's menu leaves the tree with a `[data-exiting]`
459    /// attribute, and this is the flag that stands in for it.
460    pub fn exiting(mut self, v: bool) -> Self {
461        self.exiting = v;
462        self
463    }
464
465    pub(crate) fn embedded(mut self, panel_bounds: PanelBounds) -> Self {
466        self.deferred = false;
467        self.panel_bounds = Some(panel_bounds);
468        self
469    }
470
471    pub(crate) fn focus_first(mut self, state: gpui::Entity<bool>) -> Self {
472        self.focus_first = Some(state);
473        self
474    }
475
476    pub(crate) fn on_back(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
477        self.on_back = Some(std::sync::Arc::new(f));
478        self
479    }
480
481    /// `children` on `Dropdown.Item` — replaces an item's label.
482    ///
483    /// The closure receives the item's key and the row's state: `isSelected`,
484    /// `isIndeterminate`, `isFocused`, `isPressed` and `isDisabled`, which are
485    /// the values v3 passes into the same render prop. The press is a frame
486    /// behind the pointer or activation key, because gpui reports it to a handler.
487    pub fn item_content(
488        mut self,
489        render: impl Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static,
490    ) -> Self {
491        self.item_content = Some(std::sync::Arc::new(render));
492        self
493    }
494
495    /// `children` on `Dropdown.ItemIndicator` — replaces the built-in mark.
496    pub fn indicator_content(
497        mut self,
498        render: impl Fn(&SharedString, bool, bool) -> AnyElement + 'static,
499    ) -> Self {
500        self.indicator_content = Some(std::sync::Arc::new(render));
501        self
502    }
503
504    /// The leading element of each row, drawn where [`MenuItem::icon`]'s
505    /// asset goes and in its place.
506    ///
507    /// HeroGPUI extension: v3 composes a leading icon as an ordered child of
508    /// `Dropdown.Item`, and an asset path cannot carry an element the caller
509    /// draws. The closure receives the item's key and the same row state as
510    /// [`Menu::item_content`]; `None` keeps the item's own icon, if any. The
511    /// row's text colour is inherited, so text-coloured content follows the
512    /// danger, disabled and highlight colours. Forwarded to submenus.
513    pub fn item_start_content(
514        mut self,
515        render: impl Fn(&SharedString, crate::util::InteractiveState) -> Option<AnyElement> + 'static,
516    ) -> Self {
517        self.item_start_content = Some(std::sync::Arc::new(render));
518        self
519    }
520
521    /// `type` on `Dropdown.ItemIndicator` — a check mark or a dot.
522    pub fn indicator(mut self, kind: IndicatorKind) -> Self {
523        self.indicator = kind;
524        self
525    }
526
527    /// The fill a hovered menu row takes, in place of `--default`.
528    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
529        self.row_hover_bg = Some(color.into());
530        self
531    }
532
533    /// Paired highlight text/icon color for hovered, focused and open-submenu
534    /// rows. Disabled rows retain their disabled treatment. Forwarded to submenus.
535    pub fn row_hover_foreground(mut self, color: impl Into<gpui::Hsla>) -> Self {
536        self.row_hover_foreground = Some(color.into());
537        self
538    }
539
540    /// The panel's corner radius, in place of the owning `container_radius`
541    /// helper. The panel's entry zoom interpolates the same value, so both
542    /// follow the override.
543    ///
544    /// Internal: only [`Dropdown`] reaches it, by forwarding its own
545    /// [`Dropdown::radius`], so the standalone menu keeps the helper. Not a v3
546    /// prop; the removed v2 `radius` prop is prohibited and this is a
547    /// per-component repository extension.
548    pub(crate) fn radius(mut self, radius: impl Into<Pixels>) -> Self {
549        self.radius = Some(radius.into());
550        self
551    }
552
553    /// `selectionMode` — `None` (the default) makes items pure actions.
554    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
555        self.selection_mode = mode;
556        self
557    }
558
559    /// `selectedKeys` — the controlled selection.
560    pub fn selected_keys(
561        mut self,
562        keys: impl IntoIterator<Item = impl Into<SharedString>>,
563    ) -> Self {
564        self.selected_keys = keys.into_iter().map(Into::into).collect();
565        self.selection_is_controlled = true;
566        self
567    }
568
569    /// `defaultSelectedKeys` — seeds the menu's own selection state.
570    pub fn default_selected_keys(
571        mut self,
572        keys: impl IntoIterator<Item = impl Into<SharedString>>,
573    ) -> Self {
574        self.default_selected_keys = keys.into_iter().map(Into::into).collect();
575        self
576    }
577
578    /// `disallowEmptySelection` — prevents removing the last selected item.
579    pub fn disallow_empty_selection(mut self, value: bool) -> Self {
580        self.disallow_empty_selection = value;
581        self
582    }
583
584    /// `disabledKeys` — items that cannot be activated.
585    pub fn disabled_keys(
586        mut self,
587        keys: impl IntoIterator<Item = impl Into<SharedString>>,
588    ) -> Self {
589        self.disabled_keys = keys.into_iter().map(Into::into).collect();
590        self
591    }
592
593    /// `onSelectionChange` — the whole selection after an item is activated.
594    pub fn on_selection_change(
595        mut self,
596        f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
597    ) -> Self {
598        self.on_selection_change = Some(std::sync::Arc::new(f));
599        self
600    }
601
602    /// `onAction` — an item was activated, independent of any selection.
603    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
604        self.on_action = Some(std::sync::Arc::new(f));
605        self
606    }
607
608    /// Sets the key of the selected item.
609    pub fn selected_key(mut self, key: impl Into<SharedString>) -> Self {
610        self.selected_key = Some(key.into());
611        self
612    }
613
614    /// The one slot for caller-owned low-level styling: GPUI's styling methods
615    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
616    /// applied to the menu's root element after every value the composition
617    /// and the active theme chose, so they win. The panel inside paints its
618    /// own chrome, so this reaches the surface it floats in, not the panel.
619    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
620        crate::util::refine_sx(&mut self.sx, style);
621        self
622    }
623}
624
625impl RenderOnce for Menu {
626    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
627        let base_id = self.id.clone();
628        let overlay_token = if let Some(token) = self.overlay_token.clone() {
629            Some(token)
630        } else if self.on_dismiss.is_some() {
631            let (_, token) = crate::util::overlay_scope(
632                window,
633                cx,
634                element_id::scoped(&base_id, "overlay"),
635                true,
636                self.exiting,
637            );
638            Some(token)
639        } else {
640            None
641        };
642        let (selected_keys, selection_own) = crate::util::controlled(
643            window,
644            cx,
645            element_id::scoped(&base_id, "selected"),
646            self.selection_is_controlled
647                .then(|| self.selected_keys.clone()),
648            self.default_selected_keys.clone(),
649        );
650        self.selected_keys = selected_keys;
651        // Which submenu is open, if any -- held as the open child's own
652        // `ElementId` (`{id}-sub-{key}`), which is also the id the child menu
653        // renders under. `use_keyed_state` takes `cx` mutably, so it precedes
654        // everything that borrows the theme.
655        let submenu_state =
656            window.use_keyed_state(element_id::scoped(&base_id, "submenu"), cx, |_, _| {
657                None::<gpui::ElementId>
658            });
659        let mut submenu_open = submenu_state.read(cx).clone();
660        let submenu_focus =
661            window.use_keyed_state(element_id::scoped(&base_id, "submenu-focus"), cx, |_, _| {
662                false
663            });
664        let focus_first = self
665            .focus_first
666            .as_ref()
667            .is_some_and(|state| *state.read(cx));
668        let dismiss = self.on_dismiss.clone().map(|cb| {
669            let submenu_state = submenu_state.clone();
670            let submenu_focus = submenu_focus.clone();
671            std::rc::Rc::new(move |refocus: &bool, window: &mut Window, cx: &mut App| {
672                submenu_state.update(cx, |value, cx| {
673                    if value.is_some() {
674                        *value = None;
675                        cx.notify();
676                    }
677                });
678                submenu_focus.update(cx, |value, _| *value = false);
679                cb(refocus, window, cx);
680            }) as OnDismiss
681        });
682        // The keyboard's own state: which row it is on, the handle that receives
683        // the keys, and the letters typed so far.
684        let focus_handle = self.focus_handle.clone().unwrap_or_else(|| {
685            window
686                .use_keyed_state(element_id::scoped(&base_id, "focus"), cx, |_, cx| {
687                    cx.focus_handle().tab_stop(true)
688                })
689                .read(cx)
690                .clone()
691        });
692        let cursor = window.use_keyed_state(element_id::scoped(&base_id, "cursor"), cx, |_, _| {
693            None::<usize>
694        });
695        let mut cursor_at = *cursor.read(cx);
696        // `.dropdown__popover` is `overflow-y-auto`, and React Aria keeps the
697        // focused row in view. `use_keyed_state` takes `cx` mutably, so the
698        // handle precedes the theme.
699        let menu_scroll =
700            window.use_keyed_state(element_id::scoped(&base_id, "scroll"), cx, |_, _| {
701                gpui::ScrollHandle::new()
702            });
703        let menu_scroll_now = menu_scroll.read(cx).clone();
704        let typed = window.use_keyed_state(element_id::scoped(&base_id, "typed"), cx, |_, _| {
705            crate::list_nav::Typeahead::default()
706        });
707        // One hover/press slot per item, for an `item_content` or
708        // `item_start_content` closure. The slots exist only when one is set:
709        // `track_interaction`'s handlers cost a frame of state, and the
710        // closures are the only readers (the press v3's `Dropdown.Item`
711        // render props document).
712        let interaction: Vec<crate::util::Interaction> = if self.item_content.is_some()
713            || self.item_start_content.is_some()
714            || self.row_hover_foreground.is_some()
715        {
716            (0..self.items.len())
717                .map(|i| {
718                    crate::util::interaction(
719                        element_id::scoped(
720                            &element_id::indexed(&base_id, "item", i),
721                            "interaction",
722                        ),
723                        window,
724                        cx,
725                    )
726                })
727                .collect()
728        } else {
729            Vec::new()
730        };
731        // A menu takes focus when it opens, which is what makes the arrows work
732        // without a click first. The one-shot re-arms while the menu plays its
733        // exit, so a menu that reopens after a dismissal -- a pick or Escape
734        // hands the focus back to the trigger -- is keyboard-driven again.
735        let autofocus = element_id::scoped(&base_id, "autofocus");
736        if self.exiting {
737            let done = window.use_keyed_state(autofocus, cx, |_, _| false);
738            done.update(cx, |d, _| *d = false);
739            // A trigger toggle or a controlled close shuts the menu without
740            // running `dismiss`, so an open child would paint full-size
741            // beside its exiting parent and greet the next open. Exiting is
742            // the close every path funnels through, which is where the child
743            // goes quiet.
744            submenu_state.update(cx, |value, cx| {
745                if value.is_some() {
746                    *value = None;
747                    cx.notify();
748                }
749            });
750            submenu_focus.update(cx, |value, _| *value = false);
751            submenu_open = None;
752        } else if focus_first {
753            window.focus(&focus_handle, cx);
754        } else if self.on_back.is_none() {
755            crate::util::focus_once(window, cx, autofocus, &focus_handle);
756        }
757
758        // The rows a keyboard can land on -- an item that is not disabled -- and
759        // the text a typed letter searches.
760        let stops: Vec<usize> = self
761            .items
762            .iter()
763            .enumerate()
764            .filter(|(_, item)| match item {
765                MenuItem::Item { key, .. } => !self.disabled_keys.contains(key),
766                _ => false,
767            })
768            .map(|(i, _)| i)
769            .collect();
770        if let Some(stale) = cursor_at.filter(|index| !stops.contains(index)) {
771            cursor_at = stops
772                .iter()
773                .copied()
774                .find(|index| *index > stale)
775                .or_else(|| stops.iter().rev().copied().find(|index| *index < stale))
776                .or_else(|| stops.first().copied());
777            cursor.update(cx, |value, cx| {
778                *value = cursor_at;
779                cx.notify();
780            });
781        }
782        if focus_first {
783            if let Some(first) = stops.first().copied() {
784                cursor.update(cx, |value, cx| {
785                    *value = Some(first);
786                    cx.notify();
787                });
788                cursor_at = Some(first);
789            }
790            if let Some(state) = &self.focus_first {
791                state.update(cx, |value, _| *value = false);
792            }
793        }
794        let labels: Vec<String> = self
795            .items
796            .iter()
797            .map(|item| match item {
798                MenuItem::Item { label, .. } => label.to_string(),
799                _ => String::new(),
800            })
801            .collect();
802        let item_keys: Vec<SharedString> = self
803            .items
804            .iter()
805            .map(|item| match item {
806                MenuItem::Item { key, .. } => key.clone(),
807                _ => SharedString::default(),
808            })
809            .collect();
810        // Whether each row is a submenu trigger. Such a row opens a child
811        // panel instead of ending the menu, so activating it must not close
812        // the parent -- React Aria returns before the close for a trigger.
813        let item_has_submenu: Vec<bool> = self
814            .items
815            .iter()
816            .map(|item| match item {
817                MenuItem::Item { submenu, .. } => !submenu.is_empty(),
818                _ => false,
819            })
820            .collect();
821        // Whether each row hands its interaction to its own content. Enter and
822        // Space must leave such a row alone: the hosted control answers them.
823        let item_is_interactive: Vec<bool> = self
824            .items
825            .iter()
826            .map(|item| match item {
827                MenuItem::Item {
828                    is_interactive,
829                    submenu,
830                    ..
831                } => *is_interactive && submenu.is_empty(),
832                _ => false,
833            })
834            .collect();
835        let item_bounds = self
836            .items
837            .iter()
838            .map(|item| match item {
839                MenuItem::Item { key, submenu, .. } if !submenu.is_empty() => {
840                    Some(window.use_keyed_state(
841                        element_id::scoped(
842                            &element_id::scoped(&element_id::scoped(&base_id, "item"), key.clone()),
843                            "bounds",
844                        ),
845                        cx,
846                        |_, _| None::<Bounds<Pixels>>,
847                    ))
848                }
849                _ => None,
850            })
851            .collect::<Vec<_>>();
852        let all_panel_bounds = self
853            .panel_bounds
854            .clone()
855            .unwrap_or_else(|| std::rc::Rc::new(std::cell::RefCell::new(Vec::new())));
856        // The union Vec's lifetime is one frame by construction: the top-level
857        // menu allocates it here, in its own render, and gpui re-renders every
858        // frame the panel is mounted. Both canvases below push once per frame
859        // and the outside-press listener captured in this same render reads it,
860        // so the union is always exactly this frame's composite panels -- no
861        // stale bounds can outlive the frame that drew them. Never hoist this
862        // Rc into keyed state: a Vec that survived frames would accumulate
863        // every panel position it ever had and swallow outside presses near
864        // old panel locations.
865
866        let colors = cx.colors();
867        let menu_theme = cx.theme().components.menu.resolve(&self.recipes);
868        self.panel_min_width = self.panel_min_width.or(menu_theme.panel_min_width);
869        self.panel_max_width = self.panel_max_width.or(menu_theme.panel_max_width);
870        self.panel_max_height = self.panel_max_height.or(menu_theme.panel_max_height);
871        self.panel_padding = self.panel_padding.or(menu_theme.panel_padding);
872        self.panel_gap = self.panel_gap.or(menu_theme.panel_gap);
873        self.row_height = self.row_height.or(menu_theme.row_height);
874        self.row_padding_x = self.row_padding_x.or(menu_theme.row_padding_x);
875        self.row_padding_y = self.row_padding_y.or(menu_theme.row_padding_y);
876        self.row_text_size = self.row_text_size.or(menu_theme.row_text_size);
877        self.row_gap = self.row_gap.or(menu_theme.row_gap);
878        self.radius = self.radius.or(menu_theme.radius);
879        self.separator_inset = self.separator_inset.or(menu_theme.separator_inset);
880        self.separator_thickness = self.separator_thickness.or(menu_theme.separator_thickness);
881        if !self.animate_entry_is_set {
882            if let Some(animate) = menu_theme.animate_entry {
883                self.animate_entry = animate;
884            }
885        }
886        if self.row_hover_bg.is_none() {
887            self.row_hover_bg = menu_theme.row_hover_bg.map(|color| color.resolve(colors));
888        }
889        if self.row_hover_foreground.is_none() {
890            self.row_hover_foreground = menu_theme
891                .row_hover_foreground
892                .map(|color| color.resolve(colors));
893        }
894        let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
895        let dropdown_composition = self.dropdown_composition;
896        // The panel's entry zoom interpolates the panel's own radius, so one
897        // binding feeds both the painted shape and the animation.
898        let radius = self
899            .radius
900            .unwrap_or_else(|| crate::util::container_radius(cx));
901
902        let row_height = self.row_height.unwrap_or(px(36.));
903        let row_padding_x = self.row_padding_x.unwrap_or(if dropdown_composition {
904            px(10.)
905        } else {
906            px(8.)
907        });
908        let row_text_size = self.row_text_size.unwrap_or(px(14.));
909        let row_gap = self.row_gap.unwrap_or(px(12.));
910        let separator_inset = self.separator_inset;
911        let separator_thickness = self
912            .separator_thickness
913            .unwrap_or_else(|| cx.layout().border_width);
914        let panel_padding =
915            self.panel_padding
916                .unwrap_or(if dropdown_composition { px(6.) } else { px(4.) });
917        let panel = gpui::div()
918            .relative()
919            .flex()
920            .flex_col()
921            // `.dropdown__popover` is `md:min-w-55` (220px) and the menu inside
922            // it is `gap-0.5 p-1` -- `.dropdown__menu` overrides `.menu`'s
923            // `gap-1` with half a step.
924            .min_w(self.panel_min_width.unwrap_or(px(220.)))
925            // `max-w-[48svw]`. Without a ceiling a described row's copy set
926            // the menu's width outright, so a long description could widen the
927            // popover across half the window.
928            .max_w(self.panel_max_width.unwrap_or(window.viewport_size().width * 0.48))
929            .gap(self.panel_gap.unwrap_or(px(2.)))
930            .p(panel_padding)
931            .bg(colors.overlay.background)
932            .rounded(radius)
933            .shadow(cx.layout().overlay_shadow.clone());
934        let mut panel = panel
935            // A long menu scrolls rather than being clipped, and gpui needs an
936            // id for that. Pinned `.dropdown__popover` owns the overflow
937            // (`overflow-y-auto`) while the menu inside is `overflow-clip`, and
938            // React Aria caps the popover at the space the viewport leaves
939            // (`calculatePosition`'s `getMaxHeight`). Inside a
940            // height-constraining positioner -- the dropdown root and each
941            // submenu popover -- the panel carries a viewport-relative bound so
942            // the positioner's capped pass sizes it, and a short menu keeps its
943            // natural height. A standalone menu keeps the old window share.
944            .id(element_id::scoped(&base_id, "list"))
945            .overflow_y_scroll()
946            .track_scroll(&menu_scroll_now)
947            .track_focus(&focus_handle)
948            .key_context("Menu");
949        // `menu/menu.js` renders the RAC `Menu`, and
950        // `react-aria/dist/private/menu/useMenu.js` is a flat `role: 'menu'` on
951        // the list element — which is this panel, the element that holds the
952        // rows and the keyboard focus. Upstream names it through
953        // `aria-labelledby` pointing at the trigger and warns when neither
954        // `aria-label` nor `aria-labelledby` is supplied; the port has no id
955        // graph and `Menu` has no label prop in v3's API, so it is unnamed.
956        panel = panel.a11y(a11y::Role::Menu);
957        if dropdown_composition || !self.deferred {
958            // The surface already carries the positioner's bound; the panel
959            // stretches to the surface's resolved height. A percentage `max_h`
960            // cannot tunnel through the auto-height surface -- it would keep
961            // its natural height with padding-only scroll room -- while flex
962            // stretch follows the resolved size.
963            panel = panel.self_stretch();
964        } else {
965            panel = panel.max_h(window.viewport_size().height * 0.6);
966        }
967        if let Some(max_height) = self.panel_max_height {
968            panel = panel.max_h(max_height);
969        }
970        if let Some(label) = self.panel_debug_label {
971            panel = panel.debug_selector(move || label.to_owned());
972        }
973
974        // v3 gives a floating panel no border: it is `bg-overlay shadow-overlay`
975        // and a radius, and dark mode's inset hairline is what separates the
976        // panel from the page.
977        if let Some(hairline) = cx.layout().overlay_hairline {
978            panel = panel
979                .border(cx.layout().border_width)
980                .border_color(hairline);
981        }
982
983        if !stops.is_empty() {
984            let held = cursor.clone();
985            let stops_for_keys = stops;
986            let typed_keys = typed;
987            let on_action = self.on_action.clone();
988            let on_selection_change = self.on_selection_change.clone();
989            let selection_own_for_keys = selection_own.clone();
990            let key_scroll = menu_scroll_now;
991            let mode = self.selection_mode;
992            let disallow_empty = self.disallow_empty_selection;
993            let selected_now = self.selected_keys.clone();
994            let keys = item_keys;
995            let has_submenu = item_has_submenu;
996            let is_interactive_for_keys = item_is_interactive;
997            let submenu_open_for_keys = submenu_state.clone();
998            let submenu_focus_for_keys = submenu_focus.clone();
999            let submenu_base_for_keys = base_id.clone();
1000            let on_back = self.on_back.clone();
1001            let local_submenu = submenu_state.clone();
1002            let local_submenu_focus = submenu_focus.clone();
1003            let dismiss = dismiss.clone();
1004            let interaction_for_keys = interaction.clone();
1005            panel = panel.on_key_down(move |event, window, cx| {
1006                let key = event.keystroke.key.as_str();
1007                let from = (*held.read(cx)).filter(|index| stops_for_keys.contains(index));
1008                if key == "left" {
1009                    if let Some(cb) = &on_back {
1010                        local_submenu.update(cx, |value, cx| {
1011                            if value.is_some() {
1012                                *value = None;
1013                                cx.notify();
1014                            }
1015                        });
1016                        local_submenu_focus.update(cx, |value, _| *value = false);
1017                        cb(window, cx);
1018                        cx.stop_propagation();
1019                    }
1020                    return;
1021                }
1022                if key == "right" {
1023                    let Some(i) = from else {
1024                        return;
1025                    };
1026                    if has_submenu.get(i).copied().unwrap_or(false) {
1027                        let Some(item_key) = keys.get(i) else {
1028                            return;
1029                        };
1030                        let open_key = element_id::scoped(
1031                            &element_id::scoped(&submenu_base_for_keys, "sub"),
1032                            item_key.clone(),
1033                        );
1034                        submenu_open_for_keys.update(cx, |value, cx| {
1035                            *value = Some(open_key);
1036                            cx.notify();
1037                        });
1038                        submenu_focus_for_keys.update(cx, |value, _| *value = true);
1039                        cx.stop_propagation();
1040                    }
1041                    return;
1042                }
1043                // Pinned React Aria 3.51.0 binds PageUp/PageDown through the
1044                // menu's `useSelectableCollection`, which only runs while the
1045                // panel is mounted: a closed trigger answers no page key at
1046                // all. The handlers also require `manager.focusedKey != null`
1047                // -- a mouse-opened menu has a null cursor until an arrow (or
1048                // a keyboard open's focus-first) seats one, and the page keys
1049                // must stay inert until then. With a cursor the list is
1050                // non-scrollable -- HeroUI v3.2.4 puts the overflow scrolling
1051                // on the Popover while the Menu element is `overflow-clip` --
1052                // so a page takes the enabled end: `stops` already omits
1053                // disabled rows, whatever the menu's length or scroll state.
1054                let page_move = match key {
1055                    "pagedown" if from.is_some() => stops_for_keys.last().copied(),
1056                    "pageup" if from.is_some() => stops_for_keys.first().copied(),
1057                    _ => None,
1058                }
1059                .filter(|next| Some(*next) != from);
1060                match page_move.map_or_else(
1061                    || crate::list_nav::resolve(&stops_for_keys, from, key, false),
1062                    crate::list_nav::Move::To,
1063                ) {
1064                    crate::list_nav::Move::To(next) => {
1065                        held.update(cx, |v, cx| {
1066                            *v = Some(next);
1067                            cx.notify();
1068                        });
1069                        // Keep the focused row on screen: a highlight that walks
1070                        // out of the panel reads as the arrows having stopped.
1071                        key_scroll.scroll_to_item(next);
1072                    }
1073                    crate::list_nav::Move::Activate => {
1074                        let Some(i) = from else {
1075                            return;
1076                        };
1077                        let Some(item_key) = keys.get(i).cloned() else {
1078                            return;
1079                        };
1080                        // An interactive row is not a button: the element its
1081                        // `item_content` drew owns Enter and Space, exactly as
1082                        // it owns the pointer.
1083                        if is_interactive_for_keys.get(i).copied().unwrap_or(false) {
1084                            return;
1085                        }
1086                        if let Some(slot) = interaction_for_keys.get(i) {
1087                            crate::util::begin_keyboard_press(slot, event, window, cx);
1088                        }
1089                        let has_submenu = has_submenu[i];
1090                        // A submenu trigger opens its child; it is neither a
1091                        // selection nor a menu-level action in React Aria.
1092                        if has_submenu {
1093                            let open_key = element_id::scoped(
1094                                &element_id::scoped(&submenu_base_for_keys, "sub"),
1095                                item_key,
1096                            );
1097                            submenu_open_for_keys.update(cx, |value, cx| {
1098                                *value = Some(open_key);
1099                                cx.notify();
1100                            });
1101                            submenu_focus_for_keys.update(cx, |value, _| *value = true);
1102                            return;
1103                        }
1104                        if crate::selection::reports_changes(mode) {
1105                            let next = crate::selection::next_selection(
1106                                &selected_now,
1107                                &item_key,
1108                                mode,
1109                                disallow_empty,
1110                            );
1111                            let blocked_last_removal = disallow_empty
1112                                && selected_now.len() == 1
1113                                && selected_now.contains(&item_key);
1114                            if !blocked_last_removal {
1115                                if let Some(held) = &selection_own_for_keys {
1116                                    held.update(cx, |value, cx| {
1117                                        *value = next.clone();
1118                                        cx.notify();
1119                                    });
1120                                }
1121                                if let Some(cb) = &on_selection_change {
1122                                    cb(&next, window, cx);
1123                                }
1124                            }
1125                        }
1126                        if let Some(cb) = &on_action {
1127                            cb(&item_key, window, cx);
1128                        }
1129                        // React Aria always closes for Enter. Space stays open
1130                        // only in multiple mode, so another item can be ticked.
1131                        // The focus is deliberately not sent back to the
1132                        // trigger from a key: gpui activates a focused element
1133                        // on key up, which would reopen the menu.
1134                        if key == "enter" || mode != SelectionMode::Multiple {
1135                            if let Some(cb) = &dismiss {
1136                                cb(&false, window, cx);
1137                            }
1138                        }
1139                    }
1140                    crate::list_nav::Move::Ignore => {
1141                        if !crate::list_nav::is_typeahead_key(key) {
1142                            return;
1143                        }
1144                        let now = web_time::Instant::now();
1145                        let (query, repeat) = typed_keys.update(cx, |t, _| {
1146                            let query = t.push(key, now);
1147                            (query, t.is_repeat())
1148                        });
1149                        if let Some(found) = crate::list_nav::typeahead(
1150                            &labels,
1151                            &stops_for_keys,
1152                            from,
1153                            &query,
1154                            repeat,
1155                        ) {
1156                            held.update(cx, |v, cx| {
1157                                *v = Some(found);
1158                                cx.notify();
1159                            });
1160                        }
1161                    }
1162                }
1163            });
1164        }
1165
1166        let mut open_submenu = None;
1167        for (i, item) in self.items.into_iter().enumerate() {
1168            match item {
1169                MenuItem::Separator => {
1170                    // `menu.css:8-11` and `dropdown.css:127-130` both inset the
1171                    // rule: `[data-slot="separator"] { @apply ms-[3%] w-[94%] }`,
1172                    // centred inside the panel's content box. `list_box.rs`
1173                    // already ports the identical rule; this panel used to draw
1174                    // the band full bleed.
1175                    //
1176                    // `separator_inset` replaces the proportional inset with an
1177                    // absolute one on each edge, for a panel that has to sit
1178                    // beside a platform menu whose own rule is a fixed inset
1179                    // rather than a share of the width.
1180                    panel = panel.child(match separator_inset {
1181                        Some(inset) => gpui::div().w_full().my(px(4.)).px(inset).child(
1182                            gpui::div()
1183                                .w_full()
1184                                .h(separator_thickness)
1185                                .bg(colors.separator),
1186                        ),
1187                        None => gpui::div()
1188                            .my(px(4.))
1189                            .mx(gpui::relative(0.03))
1190                            .w(gpui::relative(0.94))
1191                            .h(separator_thickness)
1192                            .bg(colors.separator),
1193                    });
1194                }
1195                MenuItem::SectionLabel(label) => {
1196                    panel = panel.child(
1197                        gpui::div()
1198                            .px(px(8.))
1199                            .pt(px(6.))
1200                            .pb(px(4.))
1201                            .text_size(px(12.))
1202                            .line_height(px(16.))
1203                            .font_weight(gpui::FontWeight::MEDIUM)
1204                            .text_color(colors.muted)
1205                            .child(label.to_string()),
1206                    );
1207                }
1208                MenuItem::Item {
1209                    key,
1210                    label,
1211                    shortcut,
1212                    icon,
1213                    is_danger,
1214                    description,
1215                    submenu,
1216                    is_interactive,
1217                } => {
1218                    // Either the single controlled key or membership of the
1219                    // selection set marks an item.
1220                    let is_selected = self.selected_key.as_ref() == Some(&key)
1221                        || self.selected_keys.contains(&key);
1222                    let is_item_disabled = self.disabled_keys.contains(&key);
1223                    let has_submenu = !submenu.is_empty();
1224                    // A submenu trigger already skips the row click; the flag
1225                    // only has meaning on a plain row.
1226                    let row_is_interactive = is_interactive && !has_submenu;
1227                    let open_key =
1228                        element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1229                    let highlighted = !is_item_disabled
1230                        && (cursor_at == Some(i)
1231                            || submenu_open.as_ref() == Some(&open_key)
1232                            || interaction.get(i).is_some_and(|slot| slot.read(cx).0));
1233                    let highlight_foreground = self.row_hover_foreground.filter(|_| highlighted);
1234                    let text_color = if let Some(color) = highlight_foreground {
1235                        color
1236                    } else if is_item_disabled {
1237                        colors.muted
1238                    } else if is_danger {
1239                        colors.danger.color
1240                    } else {
1241                        colors.foreground
1242                    };
1243                    let mut row = gpui::div()
1244                        .id(element_id::indexed(&base_id, "item", i))
1245                        .relative()
1246                        .flex()
1247                        // `.menu-item` is `w-full`: the row takes the menu's
1248                        // width rather than its own content's.
1249                        .w_full()
1250                        .items_center()
1251                        .gap(row_gap)
1252                        .px(row_padding_x)
1253                        .rounded(crate::util::soft_radius(cx))
1254                        .text_size(row_text_size)
1255                        .line_height(px(20.))
1256                        .text_color(text_color);
1257                    if let Some(recorded_item_bounds) = item_bounds[i].clone() {
1258                        row = row.child(
1259                            gpui::canvas(
1260                                move |bounds, _, cx| {
1261                                    recorded_item_bounds.update(cx, |value, cx| {
1262                                        if value.as_ref() != Some(&bounds) {
1263                                            *value = Some(bounds);
1264                                            cx.notify();
1265                                        }
1266                                    });
1267                                    bounds
1268                                },
1269                                |_, _, _, _| {},
1270                            )
1271                            .absolute()
1272                            .inset_0(),
1273                        );
1274                    }
1275                    // `.menu-item` is `min-h-9 py-1.5`; a described row grows
1276                    // past the minimum instead of clipping its second line.
1277                    row = row
1278                        .min_h(row_height)
1279                        .py(self.row_padding_y.unwrap_or(px(6.)));
1280                    // `react-aria/dist/private/menu/useMenuItem.js` decides the
1281                    // row's role in one place: `let role = 'menuitem'`, and
1282                    // then, *only when the row is not a submenu trigger*,
1283                    // `menuitemradio` in single-selection mode and
1284                    // `menuitemcheckbox` in multiple. `aria-checked` follows the
1285                    // same guard (`selectionMode !== 'none' && !isTrigger`), and
1286                    // a submenu trigger takes `aria-expanded` instead. Its
1287                    // `aria-haspopup` and `aria-controls` companions have no
1288                    // gpui builder (see `crate::a11y`).
1289                    let item_role = match (has_submenu, self.selection_mode) {
1290                        (true, _) | (false, SelectionMode::None) => a11y::Role::MenuItem,
1291                        (false, SelectionMode::Single) => a11y::Role::MenuItemRadio,
1292                        (false, SelectionMode::Multiple) => a11y::Role::MenuItemCheckBox,
1293                    };
1294                    // `aria-describedby` joins the description node and the
1295                    // keyboard-shortcut node, in that order.
1296                    let described = match (&description, &shortcut) {
1297                        (None, None) => None,
1298                        (Some(d), None) => Some(d.clone()),
1299                        (None, Some(k)) => Some(k.clone()),
1300                        (Some(d), Some(k)) => Some(SharedString::from(format!("{d} {k}"))),
1301                    };
1302                    row = row.a11y_named(
1303                        item_role,
1304                        &a11y::Name::labelled(label.clone()).described(described),
1305                    );
1306                    if has_submenu {
1307                        let open_key =
1308                            element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1309                        row = row.a11y_expanded(submenu_open.as_ref() == Some(&open_key));
1310                    } else if self.selection_mode != SelectionMode::None {
1311                        row = row.a11y_checked(is_selected, false);
1312                    }
1313                    if is_item_disabled {
1314                        // `status-disabled` is `--disabled-opacity`; the muted
1315                        // text alone was this port's own idea of the state.
1316                        row = row.opacity(cx.layout().disabled_opacity);
1317                    } else {
1318                        row = crate::util::cursor_interactive(row, cx);
1319                        // `.menu-item:hover` fills with `bg-default`, the full
1320                        // token, not the soft wash.
1321                        row = row.hover(move |s| s.bg(row_hover_bg));
1322                        let pointer_cursor = cursor.clone();
1323                        let pointer_focus = focus_handle.clone();
1324                        row = row.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1325                            window.focus(&pointer_focus, cx);
1326                            pointer_cursor.update(cx, |value, cx| {
1327                                *value = Some(i);
1328                                cx.notify();
1329                            });
1330                        });
1331                        let hover_cursor = cursor.clone();
1332                        let hover_focus = focus_handle.clone();
1333                        row = row.on_mouse_move(move |_, window, cx| {
1334                            if crate::util::focus_visible(cx) || *hover_cursor.read(cx) == Some(i) {
1335                                return;
1336                            }
1337                            window.focus(&hover_focus, cx);
1338                            hover_cursor.update(cx, |value, cx| {
1339                                *value = Some(i);
1340                                cx.notify();
1341                            });
1342                        });
1343                    }
1344                    row = when_selected(row, is_selected, sem_primary(cx));
1345                    if highlighted && self.row_hover_foreground.is_some() {
1346                        row = row.bg(row_hover_bg).text_color(text_color);
1347                    }
1348                    // `.menu-item` takes `status-focused` on the row the keyboard
1349                    // is on -- a ring, not a border, which would shift the row.
1350                    // Pointer hover seats the cursor for the next arrow; it
1351                    // must not paint the ring. The ring rides as an overlay
1352                    // child so its corner is concentric with the row's
1353                    // `soft_radius`, which a spread shadow cannot be: the
1354                    // shadow keeps the row's own radius at a band two pixels
1355                    // wider, and needs a blur to paint at all.
1356                    row = crate::util::with_focus_ring_overlay(
1357                        row,
1358                        crate::util::shows_focus_ring(cursor_at == Some(i), cx),
1359                        true,
1360                        crate::util::soft_radius(cx),
1361                        Vec::new(),
1362                        cx,
1363                    );
1364
1365                    // HeroUI passes React Aria's raw MenuItem state to the
1366                    // indicator. The pinned 1.20.0 state has no indeterminate
1367                    // member, so the documented value is always falsy.
1368                    let is_indeterminate = false;
1369                    let indicator_content = if let Some(render) = &self.indicator_content {
1370                        Some(render(&key, is_selected, is_indeterminate))
1371                    } else if is_selected && self.selection_mode != SelectionMode::None {
1372                        Some(match self.indicator {
1373                            IndicatorKind::Checkmark => gpui::svg()
1374                                .size(px(13.))
1375                                .path(icons::CHECK)
1376                                // svg() never inherits text colour.
1377                                .text_color(highlight_foreground.unwrap_or_else(|| sem_primary(cx)))
1378                                .into_any_element(),
1379                            IndicatorKind::Dot => gpui::div()
1380                                .size(px(6.))
1381                                .rounded_full()
1382                                .bg(highlight_foreground.unwrap_or_else(|| sem_primary(cx)))
1383                                .into_any_element(),
1384                        })
1385                    } else {
1386                        None
1387                    };
1388                    let mut indicator = if self.indicator_content.is_some()
1389                        || self.selection_mode != SelectionMode::None
1390                    {
1391                        let mut cell = gpui::div()
1392                            .size(px(16.))
1393                            .flex_none()
1394                            .flex()
1395                            .items_center()
1396                            .justify_center();
1397                        // The row's normal gap is 12px. v3 positions the 16px
1398                        // cell 4px from its content, so cancel the extra 8px.
1399                        cell = if has_submenu {
1400                            cell.ml(px(-8.))
1401                        } else {
1402                            cell.mr(px(-8.))
1403                        };
1404                        if let Some(content) = indicator_content {
1405                            cell = cell.child(content);
1406                        }
1407                        Some(cell.into_any_element())
1408                    } else {
1409                        None
1410                    };
1411                    // The indicator owns v3's leading 16px cell. Submenu rows
1412                    // move it beside the trailing submenu chevron instead.
1413                    if !has_submenu {
1414                        if let Some(content) = indicator.take() {
1415                            row = row.child(content);
1416                        }
1417                    }
1418                    // The slot's press is a frame behind the pointer, because
1419                    // gpui reports it to a handler rather than to the render
1420                    // that draws it. v3's `Dropdown.Item` render-props table
1421                    // lists no `isHovered`, so the hover the slot also tracks
1422                    // is not handed over; a row is focused when the keyboard
1423                    // cursor is on it.
1424                    let (_, recorded_press) = interaction
1425                        .get(i)
1426                        .map(|slot| *slot.read(cx))
1427                        .unwrap_or_default();
1428                    let row_state = crate::util::InteractiveState {
1429                        is_hovered: false,
1430                        is_pressed: !is_item_disabled && recorded_press,
1431                        is_focused: cursor_at == Some(i),
1432                        is_focus_visible: cursor_at == Some(i) && crate::util::focus_visible(cx),
1433                        is_selected,
1434                        is_disabled: is_item_disabled,
1435                        is_pending: false,
1436                        is_indeterminate,
1437                    };
1438                    let start_content = self
1439                        .item_start_content
1440                        .as_ref()
1441                        .and_then(|render| render(&key, row_state));
1442                    match (start_content, icon) {
1443                        (Some(content), _) => {
1444                            row = row.child(
1445                                gpui::div().flex_none().flex().items_center().child(content),
1446                            );
1447                        }
1448                        (None, Some(icon_path)) => {
1449                            row = row.child(
1450                                gpui::svg()
1451                                    // `.menu-item__indicator` is `size-4`.
1452                                    .size(px(16.))
1453                                    .path(icon_path)
1454                                    .text_color(text_color),
1455                            );
1456                        }
1457                        (None, None) => {}
1458                    }
1459                    // `children` on `Dropdown.Item` is a render function in
1460                    // v3, handed the row's state.
1461                    row = row.child(
1462                        gpui::div().flex().flex_col().flex_1().min_w_0().child(
1463                            match &self.item_content {
1464                                Some(render) => render(&key, row_state),
1465                                None => match &description {
1466                                    // `Label` over `Description`, which is how v3
1467                                    // composes a described item.
1468                                    Some(text) => gpui::div()
1469                                        .flex()
1470                                        .flex_col()
1471                                        .min_w_0()
1472                                        .child(
1473                                            gpui::div()
1474                                                .font_weight(gpui::FontWeight::MEDIUM)
1475                                                .child(label.to_string()),
1476                                        )
1477                                        .child(
1478                                            gpui::div()
1479                                            // A described row composes a
1480                                            // `Description`, which is `text-xs`.
1481                                            .text_size(px(12.))
1482                                            .line_height(px(16.))
1483                                            .text_color(highlight_foreground.unwrap_or(colors.muted))
1484                                            // `[data-slot="description"]` is
1485                                            // `text-wrap`, so its box is the
1486                                            // column's width, not its own
1487                                            // content's.
1488                                            .w_full()
1489                                            .child(text.to_string()),
1490                                        )
1491                                        .into_any_element(),
1492                                    None => gpui::div()
1493                                        .font_weight(gpui::FontWeight::MEDIUM)
1494                                        .child(label.to_string())
1495                                        .into_any_element(),
1496                                },
1497                            },
1498                        ),
1499                    );
1500                    // The slot's hover and press handlers keep the press the
1501                    // closure reads current. Disabled rows expose idle state.
1502                    if !is_item_disabled {
1503                        if let Some(slot) = interaction.get(i) {
1504                            row = crate::util::track_interaction(row, slot);
1505                        }
1506                    }
1507                    if let Some(sc) = shortcut {
1508                        row = row.child(
1509                            crate::kbd::Kbd::new()
1510                                .variant(crate::kbd::KbdVariant::Light)
1511                                .map(|kbd| match highlight_foreground {
1512                                    Some(color) => kbd.sx(move |el| el.text_color(color)),
1513                                    None => kbd,
1514                                })
1515                                .child(sc.to_string()),
1516                        );
1517                    }
1518                    // `Dropdown.SubmenuIndicator` — the chevron that says a row
1519                    // opens another panel.
1520                    if has_submenu {
1521                        row = row.child(
1522                            gpui::svg()
1523                                .size(px(13.))
1524                                .path(icons::CHEVRON_RIGHT)
1525                                .text_color(highlight_foreground.unwrap_or(colors.muted)),
1526                        );
1527                        if let Some(content) = indicator {
1528                            row = row.child(content);
1529                        }
1530                    }
1531
1532                    // `.menu-item[data-pressed]` is `scale(0.98)`. The press
1533                    // wrap comes after every visual child: children added
1534                    // after `pressed` land on the slot and fight the skin for
1535                    // width. The row is `w-full`, so its slot is too.
1536                    // The 98% press scale shrinks every child, a hosted
1537                    // control included, and it is keyed off a row press the
1538                    // interactive row no longer claims.
1539                    if !is_item_disabled && !row_is_interactive {
1540                        row = crate::anim::pressed(
1541                            row,
1542                            crate::anim::PressBox {
1543                                height: row_height,
1544                                padding_x: Some(row_padding_x),
1545                                width: None,
1546                                min_width: None,
1547                                text_size: row_text_size,
1548                                line_height: px(20.),
1549                                gap: row_gap,
1550                                radius: crate::util::soft_radius(cx),
1551                                shrink_x: false,
1552                                scale: crate::anim::PRESSED_SCALE_SUBTLE,
1553                            },
1554                            cx,
1555                        );
1556                    }
1557
1558                    if !is_item_disabled && !has_submenu && !row_is_interactive {
1559                        let on_action = self.on_action.clone();
1560                        let on_selection_change = self.on_selection_change.clone();
1561                        let selection_own = selection_own.clone();
1562                        let dismiss = dismiss.clone();
1563                        // Attached even with no callback to run, because v3's
1564                        // close happens on the click, not in a handler.
1565                        let key2 = key.clone();
1566                        let mode = self.selection_mode;
1567                        let disallow_empty = self.disallow_empty_selection;
1568                        let current = self.selected_keys.clone();
1569                        row = row.on_click(move |_, window, cx| {
1570                            if crate::selection::reports_changes(mode) {
1571                                let next = crate::selection::next_selection(
1572                                    &current,
1573                                    &key2,
1574                                    mode,
1575                                    disallow_empty,
1576                                );
1577                                let blocked_last_removal =
1578                                    disallow_empty && current.len() == 1 && current.contains(&key2);
1579                                if !blocked_last_removal {
1580                                    if let Some(held) = &selection_own {
1581                                        held.update(cx, |value, cx| {
1582                                            *value = next.clone();
1583                                            cx.notify();
1584                                        });
1585                                    }
1586                                    if let Some(cb) = &on_selection_change {
1587                                        cb(&next, window, cx);
1588                                    }
1589                                }
1590                            }
1591                            if let Some(cb) = &on_action {
1592                                cb(&key2, window, cx);
1593                            }
1594                            // A pointer pick stays open only in multiple mode.
1595                            // The trigger gets focus back from a click; only a
1596                            // keyboard key-up cannot safely refocus it.
1597                            if mode != SelectionMode::Multiple {
1598                                if let Some(cb) = &dismiss {
1599                                    cb(&true, window, cx);
1600                                }
1601                            }
1602                        });
1603                    }
1604
1605                    // `Dropdown.SubmenuTrigger`: the child panel is anchored to
1606                    // the row and opens while the row is hovered. gpui paints in
1607                    // tree order, so it goes through `util::floating` like every
1608                    // other floating surface.
1609                    if has_submenu {
1610                        let open_key =
1611                            element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1612                        let is_sub_open = submenu_open.as_ref() == Some(&open_key);
1613                        let held = submenu_state.clone();
1614                        let hover_focus = submenu_focus.clone();
1615                        let open_key2 = open_key.clone();
1616                        // The hover that opens the child panel lives on the
1617                        // wrapper, not the row: `track_interaction` above has
1618                        // claimed the row's single `on_hover` when an
1619                        // `item_content` closure is set, and gpui refuses a
1620                        // second listener on one element.
1621                        let mut slot = gpui::div()
1622                            .id(element_id::scoped(&open_key, "wrap"))
1623                            .relative()
1624                            .child(row);
1625                        if !is_item_disabled {
1626                            let click_held = submenu_state.clone();
1627                            let click_focus = submenu_focus.clone();
1628                            let click_key = open_key.clone();
1629                            slot = slot
1630                                .on_hover(move |hovered, _window, cx| {
1631                                    if *hovered {
1632                                        held.update(cx, |value, cx| {
1633                                            if value.as_ref() != Some(&open_key2) {
1634                                                *value = Some(open_key2.clone());
1635                                                cx.notify();
1636                                            }
1637                                        });
1638                                        hover_focus.update(cx, |value, _| *value = false);
1639                                    }
1640                                })
1641                                .on_click(move |_, _, cx| {
1642                                    click_held.update(cx, |value, cx| {
1643                                        *value = Some(click_key.clone());
1644                                        cx.notify();
1645                                    });
1646                                    click_focus.update(cx, |value, _| *value = false);
1647                                });
1648                        }
1649                        if is_sub_open {
1650                            open_submenu = Some((i, open_key, submenu));
1651                        }
1652                        panel = panel.child(slot);
1653                    } else {
1654                        panel = panel.child(row);
1655                    }
1656                }
1657            }
1658        }
1659
1660        // The flex surface's rectangular hull includes blank space between
1661        // unequal-height panels. The outside listener lives on the root panel
1662        // and checks the union of the actual parent and descendant bounds.
1663        if self.deferred {
1664            if let Some(cb) = dismiss.clone() {
1665                if open_submenu.is_some() {
1666                    // A submenu is a sibling of the parent panel. Keep the
1667                    // union-boundary check so its real bounds remain inside
1668                    // the menu surface; the shared token still gates Escape.
1669                    let panel_union = all_panel_bounds.clone();
1670                    if let Some(token) = overlay_token.clone() {
1671                        panel = crate::util::dismiss_on_press_outside_with_token_event(
1672                            panel,
1673                            token,
1674                            move |event, window, cx| {
1675                                let inside = panel_union.borrow().iter().any(|bounds| {
1676                                    event.position.x >= bounds.origin.x
1677                                        && event.position.x <= bounds.origin.x + bounds.size.width
1678                                        && event.position.y >= bounds.origin.y
1679                                        && event.position.y <= bounds.origin.y + bounds.size.height
1680                                });
1681                                if inside {
1682                                    crate::util::DismissResult::Declined
1683                                } else {
1684                                    cb(&true, window, cx);
1685                                    crate::util::DismissResult::Handled
1686                                }
1687                            },
1688                        );
1689                    }
1690                } else if let Some(token) = overlay_token.clone() {
1691                    // The explicit token gates a simple menu against any
1692                    // overlay above it.
1693                    panel = crate::util::dismiss_on_press_outside_with_token(
1694                        panel,
1695                        token,
1696                        move |window, cx| {
1697                            cb(&true, window, cx);
1698                            crate::util::DismissResult::Handled
1699                        },
1700                    );
1701                }
1702            }
1703        }
1704
1705        // The zoom grows the panel's own vertical padding, so the border box
1706        // the positioner places IS the painted panel: at rest the animated
1707        // padding equals the panel's natural padding (`p-1.5` under the
1708        // dropdown composition, `p-1` otherwise) and adds nothing, and
1709        // mid-animation only the internal padding flexes, never the origin.
1710        // Animating the surface instead would hang its `py` between the
1711        // placed box and the painted panel (one RAC gap plus ~6px).
1712        let zoom = crate::anim::ZoomBox::panel(panel_padding, radius);
1713        let panel = if self.exiting {
1714            crate::anim::exiting(
1715                panel,
1716                element_id::scoped(&base_id, "panel-out"),
1717                zoom,
1718                crate::anim::Motion::LIST_OUT,
1719                cx,
1720            )
1721        } else if self.animate_entry {
1722            crate::anim::entering_zoom(
1723                panel,
1724                element_id::scoped(&base_id, "panel"),
1725                zoom,
1726                crate::anim::Motion::POPOVER_IN,
1727                cx,
1728            )
1729        } else {
1730            panel.into_any_element()
1731        };
1732
1733        // Parent and child menus share one deferred surface. The submenu is its
1734        // own popover positioned against its row -- pinned RAC 1.20.0 renders
1735        // each submenu in a separate `Popover` with `placement: 'end top'` --
1736        // so it is a sibling of the parent's overflow scroller, never clipped
1737        // by it, and it flips and caps against the viewport on its own. The
1738        // positioner element itself is absolute, so it takes no flex space.
1739        // The surface carries the positioner's relative bound through to the
1740        // panel: `layout_as_root` only offers definite space, and the cap
1741        // resolves through `max_h_full` at every level -- a bare flex surface
1742        // would size to its content and shield the panel, leaving it at its
1743        // natural height with padding-only scroll room.
1744        let mut surface = gpui::div()
1745            .relative()
1746            .flex()
1747            .items_start()
1748            .gap(px(4.))
1749            .max_h_full()
1750            .child(panel);
1751        if let Some((index, submenu_id, submenu)) = open_submenu {
1752            // The row canvas records these bounds every frame, so the bridge
1753            // carries last frame's row rect and the positioner settles the
1754            // same frame the submenu opens.
1755            let row_trigger = std::rc::Rc::new(std::cell::Cell::new(
1756                item_bounds[index]
1757                    .as_ref()
1758                    .and_then(|bounds| bounds.read(cx).to_owned()),
1759            ));
1760            let mut sub = Menu::new(submenu_id.clone(), submenu)
1761                .id(element_id::scoped(&submenu_id, "menu"))
1762                .panel_debug_label("dropdown-submenu")
1763                .indicator(self.indicator)
1764                .disabled_keys(self.disabled_keys)
1765                .embedded(all_panel_bounds.clone())
1766                .focus_first(submenu_focus.clone());
1767            sub.panel_min_width = self.panel_min_width;
1768            sub.panel_max_width = self.panel_max_width;
1769            sub.panel_max_height = self.panel_max_height;
1770            sub.row_height = self.row_height;
1771            sub.row_padding_x = self.row_padding_x;
1772            sub.row_padding_y = self.row_padding_y;
1773            sub.row_text_size = self.row_text_size;
1774            sub.row_gap = self.row_gap;
1775            sub.panel_padding = self.panel_padding;
1776            sub.panel_gap = self.panel_gap;
1777            sub.separator_inset = self.separator_inset;
1778            sub.separator_thickness = self.separator_thickness;
1779            sub.animate_entry = self.animate_entry;
1780            sub.row_hover_bg = self.row_hover_bg;
1781            sub.row_hover_foreground = self.row_hover_foreground;
1782            sub.radius = self.radius;
1783            sub.recipes = self.recipes.clone();
1784            sub.animate_entry_is_set = self.animate_entry_is_set;
1785            sub.item_content = self.item_content.clone();
1786            sub.indicator_content = self.indicator_content.clone();
1787            sub.item_start_content = self.item_start_content.clone();
1788            if let Some(token) = overlay_token.clone() {
1789                sub = sub.overlay_token(token);
1790            }
1791            let close_state = submenu_state;
1792            let close_focus_state = submenu_focus;
1793            let parent_focus = focus_handle.clone();
1794            sub = sub.on_back(move |window, cx| {
1795                close_state.update(cx, |value, cx| {
1796                    *value = None;
1797                    cx.notify();
1798                });
1799                close_focus_state.update(cx, |value, _| *value = false);
1800                window.focus(&parent_focus, cx);
1801            });
1802            if let Some(cb) = self.on_action.clone() {
1803                sub = sub.on_action(move |key, window, cx| cb(key, window, cx));
1804            }
1805            if let Some(cb) = dismiss.clone() {
1806                sub = sub.on_dismiss(move |refocus, window, cx| cb(refocus, window, cx));
1807            }
1808            surface = surface.child(crate::popover::scrollable_submenu_popover(row_trigger, sub));
1809        }
1810
1811        // The union outside-press check reads this frame's panel frames. The
1812        // canvas lives on the surface, not the panel: the panel owns the
1813        // vertical scroll container, so a canvas inside it reports scrolled
1814        // content-space bounds after a wheel (its origin walks up with the
1815        // rows) and a press on a scrolled-into-view row reads as outside.
1816        // The surface never scrolls -- its only other child is the absolute
1817        // submenu positioner, which takes no flex space -- so its frame
1818        // coincides with the panel's.
1819        let registered_panel_bounds = all_panel_bounds;
1820        surface = surface.child(
1821            gpui::canvas(
1822                move |bounds, _, _| {
1823                    registered_panel_bounds.borrow_mut().push(bounds);
1824                    bounds
1825                },
1826                |_, _, _, _| {},
1827            )
1828            .absolute()
1829            .inset_0(),
1830        );
1831
1832        // Escape bubbles from the focused descendant to this root surface.
1833        let surface = if self.deferred {
1834            match dismiss {
1835                Some(cb) => match overlay_token {
1836                    Some(token) => crate::util::dismiss_on_escape_with_token(
1837                        surface,
1838                        token,
1839                        move |window, cx| {
1840                            cb(&true, window, cx);
1841                            crate::util::DismissResult::Handled
1842                        },
1843                    ),
1844                    None => surface,
1845                },
1846                None => surface,
1847            }
1848        } else {
1849            surface
1850        };
1851        let surface = crate::util::apply_sx(surface, &self.sx);
1852
1853        if self.deferred {
1854            crate::util::floating(surface).into_any_element()
1855        } else {
1856            surface.into_any_element()
1857        }
1858    }
1859}
1860
1861fn sem_primary(cx: &App) -> gpui::Hsla {
1862    cx.colors().accent.color
1863}
1864
1865fn when_selected(
1866    el: gpui::Stateful<gpui::Div>,
1867    selected: bool,
1868    color: gpui::Hsla,
1869) -> gpui::Stateful<gpui::Div> {
1870    if selected {
1871        el.bg(color.alpha(0.14)).text_color(color)
1872    } else {
1873        el
1874    }
1875}
1876
1877/// `trigger` — what opens the menu.
1878#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
1879pub enum DropdownTrigger {
1880    /// A press opens it, which is v3's default.
1881    #[default]
1882    Press,
1883    /// A press held for the theme's `long_press_ms` opens it (500ms by
1884    /// default, matching React Aria).
1885    LongPress,
1886}
1887
1888/// Dropdown wrapper: trigger + floating menu panel (`Dropdown/DropdownTrigger/
1889/// DropdownMenu` composition).
1890#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
1891#[derive(IntoElement)]
1892pub struct Dropdown {
1893    /// Keys this dropdown's own state; see [`Dropdown::id`].
1894    id: gpui::ElementId,
1895    trigger: AnyElement,
1896    /// `trigger` — press (the default) or long press.
1897    trigger_kind: DropdownTrigger,
1898    /// `isOpen` — `None` leaves the component holding the flag, seeded from
1899    /// `defaultOpen`.
1900    is_open: Option<bool>,
1901    default_open: bool,
1902    on_open_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
1903    items: Vec<MenuItem>,
1904    item_content: Option<ItemContent>,
1905    indicator_content: Option<ItemIndicatorContent>,
1906    item_start_content: Option<ItemStartContent>,
1907    selection_mode: SelectionMode,
1908    selected_keys: Vec<SharedString>,
1909    default_selected_keys: Vec<SharedString>,
1910    selection_is_controlled: bool,
1911    disallow_empty_selection: bool,
1912    disabled_keys: Vec<SharedString>,
1913    indicator: IndicatorKind,
1914    on_selection_change: Option<OnSelectionChange>,
1915    on_action: Option<OnSelect>,
1916    /// The fill a hovered menu row takes, in place of `--default`.
1917    row_hover_bg: Option<gpui::Hsla>,
1918    /// The panel's corner radius, forwarded onto the menu that paints it.
1919    radius: Option<Pixels>,
1920    placement: DropdownPlacement,
1921    /// The `sx` slot, refined over the root style at the end of render.
1922    sx: Option<Box<gpui::StyleRefinement>>,
1923    recipes: Vec<SharedString>,
1924}
1925
1926/// `placement` on `Dropdown.Popover`.
1927///
1928/// Shares the one placement vocabulary with the pickers and popover; it
1929/// previously offered only the two bottom-aligned values.
1930pub use herogpui_core::Placement as DropdownPlacement;
1931
1932impl Dropdown {
1933    /// The element id this dropdown's state is keyed by.
1934    ///
1935    /// Not a v3 prop. It matters more here than it looks: with one shared key,
1936    /// pressing any trigger on a page opened *every* menu on it, because they
1937    /// were all reading the same uncontrolled open flag.
1938    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
1939        self.id = id.into();
1940        self
1941    }
1942
1943    /// `onOpenChange` — reports the open state the trigger moves to.
1944    pub fn on_open_change(
1945        mut self,
1946        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
1947    ) -> Self {
1948        self.on_open_change = Some(std::sync::Arc::new(handler));
1949        self
1950    }
1951
1952    /// `isOpen` — also accepted positionally by [`Dropdown::new`].
1953    pub fn is_open(mut self, v: bool) -> Self {
1954        self.is_open = Some(v);
1955        self
1956    }
1957    /// `defaultOpen` — the uncontrolled initial state.
1958    ///
1959    /// Only consulted when `is_open` is not supplied; the component then owns
1960    /// the flag and its trigger toggles it.
1961    pub fn default_open(mut self, v: bool) -> Self {
1962        self.default_open = v;
1963        self
1964    }
1965
1966    /// An uncontrolled dropdown: the menu holds its own open state, seeded
1967    /// from [`Dropdown::default_open`], and the trigger toggles it.
1968    /// `trigger` — `Press` (default) or `LongPress`.
1969    pub fn trigger(mut self, kind: DropdownTrigger) -> Self {
1970        self.trigger_kind = kind;
1971        self
1972    }
1973
1974    /// Creates a dropdown that owns its open state.
1975    pub fn uncontrolled(
1976        id: impl Into<gpui::ElementId>,
1977        trigger: impl IntoElement,
1978        items: Vec<MenuItem>,
1979    ) -> Self {
1980        let mut dd = Self::new(id, trigger, items, false);
1981        dd.is_open = None;
1982        dd
1983    }
1984
1985    /// Creates a dropdown whose open state is supplied by the caller.
1986    pub fn new(
1987        id: impl Into<gpui::ElementId>,
1988        trigger: impl IntoElement,
1989        items: Vec<MenuItem>,
1990        is_open: bool,
1991    ) -> Self {
1992        Self {
1993            id: id.into(),
1994            trigger: trigger.into_any_element(),
1995            trigger_kind: DropdownTrigger::default(),
1996            is_open: Some(is_open),
1997            default_open: false,
1998            on_open_change: None,
1999            items,
2000            item_content: None,
2001            indicator_content: None,
2002            item_start_content: None,
2003            selection_mode: SelectionMode::None,
2004            selected_keys: Vec::new(),
2005            default_selected_keys: Vec::new(),
2006            selection_is_controlled: false,
2007            disallow_empty_selection: false,
2008            disabled_keys: Vec::new(),
2009            indicator: IndicatorKind::default(),
2010            on_selection_change: None,
2011            on_action: None,
2012            row_hover_bg: None,
2013            radius: None,
2014            placement: DropdownPlacement::BottomStart,
2015            sx: None,
2016            recipes: Vec::new(),
2017        }
2018    }
2019
2020    /// Named theme overlay forwarded onto the painted [`Menu`].
2021    /// Stackable; a missing name adds no override.
2022    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
2023        self.recipes.push(name.into());
2024        self
2025    }
2026
2027    /// `type` on `Dropdown.ItemIndicator`.
2028    pub fn indicator(mut self, kind: IndicatorKind) -> Self {
2029        self.indicator = kind;
2030        self
2031    }
2032
2033    /// The fill a hovered menu row takes, in place of `--default`. v3 tints
2034    /// the row with a class; this names the colour.
2035    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
2036        self.row_hover_bg = Some(color.into());
2037        self
2038    }
2039
2040    /// The panel's corner radius, in place of the owning `container_radius`
2041    /// helper. The panel's entry zoom interpolates the same value, so both
2042    /// follow the override. Not a v3 prop; the removed v2 `radius` prop is
2043    /// prohibited and this is a per-component repository extension.
2044    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
2045        self.radius = Some(radius.into());
2046        self
2047    }
2048
2049    /// `children` on `Dropdown.Item` — replaces each item's label with a
2050    /// render closure receiving its key and interactive state.
2051    pub fn item_content(
2052        mut self,
2053        render: impl Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static,
2054    ) -> Self {
2055        self.item_content = Some(std::sync::Arc::new(render));
2056        self
2057    }
2058
2059    /// `children` on `Dropdown.ItemIndicator` — replaces the built-in mark.
2060    pub fn indicator_content(
2061        mut self,
2062        render: impl Fn(&SharedString, bool, bool) -> AnyElement + 'static,
2063    ) -> Self {
2064        self.indicator_content = Some(std::sync::Arc::new(render));
2065        self
2066    }
2067
2068    /// The leading element of each row, forwarded onto the painted [`Menu`].
2069    /// See [`Menu::item_start_content`]; a HeroGPUI extension.
2070    pub fn item_start_content(
2071        mut self,
2072        render: impl Fn(&SharedString, crate::util::InteractiveState) -> Option<AnyElement> + 'static,
2073    ) -> Self {
2074        self.item_start_content = Some(std::sync::Arc::new(render));
2075        self
2076    }
2077
2078    /// `selectionMode` on `Dropdown.Menu`.
2079    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
2080        self.selection_mode = mode;
2081        self
2082    }
2083
2084    /// `selectedKeys` on `Dropdown.Menu`.
2085    pub fn selected_keys(
2086        mut self,
2087        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2088    ) -> Self {
2089        self.selected_keys = keys.into_iter().map(Into::into).collect();
2090        self.selection_is_controlled = true;
2091        self
2092    }
2093
2094    /// `defaultSelectedKeys` on `Dropdown.Menu` — seeds uncontrolled selection.
2095    pub fn default_selected_keys(
2096        mut self,
2097        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2098    ) -> Self {
2099        self.default_selected_keys = keys.into_iter().map(Into::into).collect();
2100        self
2101    }
2102
2103    /// `disallowEmptySelection` on `Dropdown.Menu`.
2104    pub fn disallow_empty_selection(mut self, value: bool) -> Self {
2105        self.disallow_empty_selection = value;
2106        self
2107    }
2108
2109    /// `disabledKeys` on `Dropdown.Menu`.
2110    pub fn disabled_keys(
2111        mut self,
2112        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2113    ) -> Self {
2114        self.disabled_keys = keys.into_iter().map(Into::into).collect();
2115        self
2116    }
2117
2118    /// `onSelectionChange` on `Dropdown.Menu`.
2119    pub fn on_selection_change(
2120        mut self,
2121        f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
2122    ) -> Self {
2123        self.on_selection_change = Some(std::sync::Arc::new(f));
2124        self
2125    }
2126
2127    /// `onAction` on `Dropdown.Menu`.
2128    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
2129        self.on_action = Some(std::sync::Arc::new(f));
2130        self
2131    }
2132
2133    /// Sets where the menu opens relative to the trigger.
2134    pub fn placement(mut self, p: DropdownPlacement) -> Self {
2135        self.placement = p;
2136        self
2137    }
2138
2139    /// The one slot for caller-owned low-level styling: GPUI's styling methods
2140    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
2141    /// applied to the dropdown's root element after every value the
2142    /// composition and the active theme chose, so they win. The trigger is
2143    /// the caller's own element and the panel paints its own chrome, so this
2144    /// reaches the wrapper they sit in.
2145    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
2146        crate::util::refine_sx(&mut self.sx, style);
2147        self
2148    }
2149}
2150
2151impl RenderOnce for Dropdown {
2152    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
2153        let _ = icons::CHEVRON_DOWN;
2154        let wrap_base_id = self.id.clone();
2155
2156        // `isOpen` wins; without it the menu holds the flag itself, which is
2157        // what `defaultOpen` promises. See `Dropdown::uncontrolled`.
2158        let (is_open, open_own) = crate::util::controlled(
2159            window,
2160            cx,
2161            element_id::scoped(&wrap_base_id, "open"),
2162            self.is_open,
2163            self.default_open,
2164        );
2165        let (selected_keys, selection_own) = crate::util::controlled(
2166            window,
2167            cx,
2168            element_id::scoped(&wrap_base_id, "selected"),
2169            self.selection_is_controlled
2170                .then(|| self.selected_keys.clone()),
2171            self.default_selected_keys.clone(),
2172        );
2173        self.selected_keys = selected_keys;
2174        // `overlay_scope` takes `cx` mutably too, so it goes here.
2175        let (phase, overlay_token) = crate::util::overlay_scope(
2176            window,
2177            cx,
2178            element_id::scoped(&wrap_base_id, "phase"),
2179            is_open,
2180            true,
2181        );
2182        let focus_first = window.use_keyed_state(
2183            element_id::scoped(&wrap_base_id, "focus-first"),
2184            cx,
2185            |_, _| false,
2186        );
2187
2188        // `trigger="longPress"` needs to know whether the button is still down
2189        // when the timer fires, so the press is a piece of state rather than a
2190        // local.
2191        let holding =
2192            window.use_keyed_state(element_id::scoped(&wrap_base_id, "holding"), cx, |_, _| {
2193                false
2194            });
2195
2196        // Where the focus goes when the menu closes. React Aria hands it back
2197        // to the trigger, and the trigger element is the caller's, so the
2198        // wrapper holds the handle. It is deliberately *not* a tab stop: gpui
2199        // keeps any tracked handle in the tab order, so Tab carries on from here
2200        // instead of starting the page over.
2201        let trigger_focus = window.use_keyed_state(
2202            element_id::scoped(&wrap_base_id, "trigger-focus"),
2203            cx,
2204            |_, cx| cx.focus_handle(),
2205        );
2206        let trigger_handle = trigger_focus.read(cx).clone();
2207        let anchor_bounds = window
2208            .use_keyed_state(
2209                element_id::scoped(&wrap_base_id, "anchor-bounds"),
2210                cx,
2211                |_, _| std::rc::Rc::new(std::cell::Cell::new(None::<Bounds<Pixels>>)),
2212            )
2213            .read(cx)
2214            .clone();
2215        let mut trigger_wrap = gpui::div()
2216            .id(element_id::scoped(&wrap_base_id, "trigger"))
2217            .track_focus(&trigger_handle)
2218            .cursor(crate::util::interactive_cursor(cx));
2219        let dismiss_own = open_own.clone();
2220        let on_open_change = self.on_open_change.clone();
2221        if on_open_change.is_some() || open_own.is_some() {
2222            let next_open = !is_open;
2223            let own = open_own;
2224            match self.trigger_kind {
2225                DropdownTrigger::Press => {
2226                    let key_own = own.clone();
2227                    let key_open_change = on_open_change.clone();
2228                    let key_focus_first = focus_first.clone();
2229                    trigger_wrap = trigger_wrap.on_key_down(move |event, window, cx| {
2230                        let key = event.keystroke.key.as_str();
2231                        if is_open || (key != "enter" && key != "space") {
2232                            return;
2233                        }
2234                        key_focus_first.update(cx, |focus, _| *focus = true);
2235                        if let Some(held) = &key_own {
2236                            held.update(cx, |value, cx| {
2237                                *value = true;
2238                                cx.notify();
2239                            });
2240                        }
2241                        if let Some(cb) = &key_open_change {
2242                            cb(&true, window, cx);
2243                        }
2244                        cx.stop_propagation();
2245                    });
2246                    let focus_first = focus_first.clone();
2247                    trigger_wrap = trigger_wrap.on_click(move |ev: &ClickEvent, w, cx| {
2248                        focus_first.update(cx, |focus, _| {
2249                            *focus = matches!(ev, ClickEvent::Keyboard(_));
2250                        });
2251                        // Uncontrolled: flip our own copy, or the trigger would
2252                        // be inert without a caller handler.
2253                        if let Some(held) = &own {
2254                            held.update(cx, |v, cx| {
2255                                *v = next_open;
2256                                cx.notify();
2257                            });
2258                        }
2259                        if let Some(cb) = &on_open_change {
2260                            cb(&next_open, w, cx);
2261                        }
2262                    });
2263                }
2264                DropdownTrigger::LongPress => {
2265                    let up_holding = holding.clone();
2266                    let pointer_focus_first = focus_first.clone();
2267                    trigger_wrap = trigger_wrap
2268                        .on_mouse_down(gpui::MouseButton::Left, {
2269                            let holding = holding;
2270                            move |_, window, cx| {
2271                                pointer_focus_first.update(cx, |focus, _| *focus = false);
2272                                holding.update(cx, |v, _| *v = true);
2273                                let holding = holding.clone();
2274                                let own = own.clone();
2275                                let on_open_change = on_open_change.clone();
2276                                let long_press_ms = cx.layout().long_press_ms;
2277                                // Open only if the button is still down when the
2278                                // timer expires; a quick click leaves it shut.
2279                                // `window.spawn` rather than `cx.spawn`: the
2280                                // callback needs a `Window`, and only a window
2281                                // async context can hand one back.
2282                                window
2283                                    .spawn(cx, async move |cx| {
2284                                        cx.background_executor()
2285                                            .timer(std::time::Duration::from_millis(long_press_ms))
2286                                            .await;
2287                                        cx.update(|window, cx| {
2288                                            if !*holding.read(cx) {
2289                                                return;
2290                                            }
2291                                            if let Some(held) = &own {
2292                                                held.update(cx, |v, cx| {
2293                                                    *v = true;
2294                                                    cx.notify();
2295                                                });
2296                                            }
2297                                            if let Some(cb) = &on_open_change {
2298                                                cb(&true, window, cx);
2299                                            }
2300                                        })
2301                                        .ok();
2302                                    })
2303                                    .detach();
2304                            }
2305                        })
2306                        .on_mouse_up(gpui::MouseButton::Left, move |_, _window, cx| {
2307                            up_holding.update(cx, |v, _| *v = false);
2308                        });
2309                }
2310            }
2311        }
2312
2313        // A flex column with `items_start` keeps the trigger at its natural
2314        // width; a plain block root would stretch it (gpui divs are
2315        // Display::Block, so a block-level flex child fills the line). The
2316        // trigger is measured the way RAC's `useOverlayPosition` positions
2317        // against the trigger rect -- the measure element only records the
2318        // bounds the popover below reads to flip and cap the panel.
2319        let trigger = crate::popover::PopoverTriggerMeasure::new(
2320            trigger_wrap.child(self.trigger),
2321            anchor_bounds.clone(),
2322        );
2323        let mut root = gpui::div()
2324            .relative()
2325            .flex()
2326            .flex_col()
2327            // `.dropdown` is `flex flex-col gap-1`.
2328            .gap(px(4.))
2329            .items_start()
2330            .child(trigger);
2331
2332        // v3 keeps a closing menu on screen for its `[data-exiting]` run.
2333        if phase != crate::util::OverlayPhase::Closed {
2334            let mut menu = Menu::new(
2335                element_id::scoped(&wrap_base_id, "menu-content"),
2336                self.items,
2337            )
2338            .id(element_id::scoped(&wrap_base_id, "menu"))
2339            .dropdown_composition()
2340            .focus_first(focus_first)
2341            .exiting(phase == crate::util::OverlayPhase::Exiting)
2342            .selection_mode(self.selection_mode)
2343            .selected_keys(self.selected_keys.clone())
2344            .disallow_empty_selection(self.disallow_empty_selection)
2345            .disabled_keys(self.disabled_keys.clone())
2346            .indicator(self.indicator);
2347            menu.item_content = self.item_content.clone();
2348            menu.indicator_content = self.indicator_content.clone();
2349            menu.item_start_content = self.item_start_content.clone();
2350            menu.recipes = self.recipes.clone();
2351            if let Some(row_hover_bg) = self.row_hover_bg {
2352                menu = menu.row_hover_bg(row_hover_bg);
2353            }
2354            if let Some(radius) = self.radius {
2355                menu = menu.radius(radius);
2356            }
2357            menu = menu.overlay_token(overlay_token);
2358            if let Some(on_action) = self.on_action.clone() {
2359                menu = menu.on_action(move |k, w, cx| on_action(k, w, cx));
2360            }
2361            let selection_cb = self.on_selection_change.clone();
2362            if selection_cb.is_some() || selection_own.is_some() {
2363                menu = menu.on_selection_change(move |keys, w, cx| {
2364                    if let Some(held) = &selection_own {
2365                        held.update(cx, |value, cx| {
2366                            *value = keys.to_vec();
2367                            cx.notify();
2368                        });
2369                    }
2370                    if let Some(cb) = &selection_cb {
2371                        cb(keys, w, cx);
2372                    }
2373                });
2374            }
2375            let dismiss_cb = self.on_open_change.clone();
2376            if dismiss_cb.is_some() || dismiss_own.is_some() {
2377                let back_to_trigger = trigger_handle;
2378                menu = menu.on_dismiss(move |refocus, window, cx| {
2379                    if let Some(held) = &dismiss_own {
2380                        held.update(cx, |v, cx| {
2381                            *v = false;
2382                            cx.notify();
2383                        });
2384                    }
2385                    if let Some(cb) = &dismiss_cb {
2386                        cb(&false, window, cx);
2387                    }
2388                    // The menu held the focus for its arrows; hand it back.
2389                    // An Enter pick runs this inside the key event, where gpui
2390                    // would activate the trigger on key up and reopen the menu
2391                    // -- the keyboard path asks for no refocus for that reason.
2392                    if *refocus {
2393                        window.focus(&back_to_trigger, cx);
2394                    }
2395                });
2396            }
2397            // RAC renders the menu in a `Popover` against the trigger: an 8px
2398            // gap (`offset ?? 8` in pinned RAC 1.20.0's `Popover`, which `Menu`
2399            // passes no offset to), flipping to the side with more room when
2400            // the menu cannot fit and capping at the available viewport height
2401            // past the 12px container padding. The shared `scrollable_popover`
2402            // owns that contract -- including `Left`/`Right`, which the old
2403            // cross-axis-only shift left to overflow -- and the panel's
2404            // `max_h_full` keeps short menus at their natural height. The menu
2405            // itself is the positioner's child: a wrapper between them would
2406            // measure and cap while the panel kept its natural size (and its
2407            // scroll range would collapse to the padding).
2408            root = root.child(crate::util::floating(crate::popover::scrollable_popover(
2409                anchor_bounds,
2410                self.placement,
2411                menu.panel_debug_label("dropdown-menu"),
2412            )));
2413        }
2414
2415        crate::util::apply_sx(root, &self.sx)
2416    }
2417}
2418
2419// The pinned `.menu-item:hover` fills with `bg-default`, the full token.
2420// `soft()` is a lighter wash that looks plausible on screen, so the check is
2421// mechanical.
2422#[cfg(test)]
2423mod hover_tokens {
2424    #[test]
2425    fn menu_rows_hover_the_full_default() {
2426        // Scan the implementation only.
2427        let source = include_str!("dropdown.rs")
2428            .split("#[cfg(test)]")
2429            .next()
2430            .expect("the implementation section is always present");
2431        assert!(
2432            source
2433                .contains("let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);"),
2434            "menu rows must default to the full `bg-default` and honor the \
2435             named override (pinned `.menu-item:hover`)"
2436        );
2437        assert!(
2438            source.contains("row = row.hover(move |s| s.bg(row_hover_bg));"),
2439            "the row hover must consume the resolved fill"
2440        );
2441        assert!(
2442            !source.contains("colors.default.soft()"),
2443            "no menu surface may hover a soft token"
2444        );
2445    }
2446}
2447
2448crate::util::impl_component_styled!(Menu, Dropdown);