Skip to main content

gpui_kit_material/components/
menu.rs

1//! M3 menus: the baseline menu, the M3 Expressive vertical menu (standard and
2//! vibrant, with groups), a popup that opens a menu from a trigger, and the
3//! exposed dropdown menu built from a text field and a menu.
4//!
5//! Reference: <https://m3.material.io/components/menus/specs> and the
6//! Material 3 Design Kit "Menu" page (inspected tokens: Expressive items are
7//! 44dp inside 2/4dp insets with 12dp padding, 20dp icons and label-large
8//! text; selected items take tertiary roles and a 12dp shape; groups have
9//! 8dp corners, a 2dp gap and the menu's 16dp outer corners; baseline items
10//! are 56/48/40dp on surface-container with secondary-container selection).
11//!
12//! Behavior comes from Kit and Base: items are Base buttons (pointer, Enter
13//! and Space activation, disabled semantics) with the shared M3 focus ring;
14//! arrow keys, Home/End and Escape use Base's shared actions; the popup is
15//! Kit's `component::popover::Popover`, which owns the overlay, outside-click
16//! and Escape dismissal, and focus capture and restoration. Material owns the
17//! visuals and the open/close motion: the surface reveals from its top on the
18//! default spatial token while fading on fast effects, and plays the same
19//! motion back on close, retargeting from wherever it is.
20use super::InteractionPreview;
21use super::focus::{RingPlacement, RingedButton};
22use super::roving::{Direction, target};
23use super::text_field::{TextField, TextFieldDensity, TextFieldVariant, type_style};
24use crate::{
25    color::ColorRole,
26    icons::{Icon, Symbol},
27    motion::{self, MotionToken},
28    theme::color,
29    tokens::{
30        elevation, state,
31        typography::{TypeRole, rem},
32    },
33};
34use gpui::{prelude::*, *};
35use gpui_base::actions::{
36    Cancel, Confirm, SelectDown, SelectFirst, SelectLast, SelectLeft, SelectRight, SelectUp,
37};
38use gpui_kit::component::popover::Popover;
39use std::rc::Rc;
40
41const MENU_CONTEXT: &str = "MaterialMenu";
42const DROPDOWN_CONTEXT: &str = "MaterialDropdown";
43
44pub(crate) fn init(cx: &mut App) {
45    cx.bind_keys([
46        KeyBinding::new("up", SelectUp, Some(MENU_CONTEXT)),
47        KeyBinding::new("down", SelectDown, Some(MENU_CONTEXT)),
48        KeyBinding::new("home", SelectFirst, Some(MENU_CONTEXT)),
49        KeyBinding::new("end", SelectLast, Some(MENU_CONTEXT)),
50        KeyBinding::new("right", SelectRight, Some(MENU_CONTEXT)),
51        KeyBinding::new("left", SelectLeft, Some(MENU_CONTEXT)),
52        KeyBinding::new("escape", Cancel, Some(MENU_CONTEXT)),
53        KeyBinding::new("enter", Confirm { secondary: false }, Some(MENU_CONTEXT)),
54        KeyBinding::new("space", Confirm { secondary: false }, Some(MENU_CONTEXT)),
55        KeyBinding::new("down", SelectDown, Some(DROPDOWN_CONTEXT)),
56        KeyBinding::new("up", SelectUp, Some(DROPDOWN_CONTEXT)),
57        KeyBinding::new(
58            "enter",
59            Confirm { secondary: false },
60            Some(DROPDOWN_CONTEXT),
61        ),
62        KeyBinding::new(
63            "space",
64            Confirm { secondary: false },
65            Some(DROPDOWN_CONTEXT),
66        ),
67    ]);
68}
69
70/// The menu families of the design kit.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
72pub enum MenuStyle {
73    /// The original M3 menu on surface-container.
74    Baseline,
75    /// M3 Expressive vertical menu on surface-container-low.
76    #[default]
77    Standard,
78    /// M3 Expressive vertical menu on tertiary-container.
79    Vibrant,
80}
81
82impl MenuStyle {
83    pub const ALL: [Self; 3] = [Self::Baseline, Self::Standard, Self::Vibrant];
84    pub const fn name(self) -> &'static str {
85        match self {
86            Self::Baseline => "Baseline",
87            Self::Standard => "Standard",
88            Self::Vibrant => "Vibrant",
89        }
90    }
91    const fn expressive(self) -> bool {
92        !matches!(self, Self::Baseline)
93    }
94}
95
96/// Baseline menu density: 56, 48 or 40dp items.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
98pub enum MenuDensity {
99    #[default]
100    Default,
101    /// Density −2.
102    Compact,
103    /// Density −4.
104    Dense,
105}
106
107impl MenuDensity {
108    pub const ALL: [Self; 3] = [Self::Default, Self::Compact, Self::Dense];
109    const fn item_height(self) -> f32 {
110        match self {
111            Self::Default => 56.,
112            Self::Compact => 48.,
113            Self::Dense => 40.,
114        }
115    }
116    /// How much shorter an Expressive item is. The kit defines densities
117    /// for the baseline menu only (8dp per two steps); md3.io offers a
118    /// dense Expressive menu, so Expressive items take half those steps,
119    /// keeping a 44dp item at least 36dp around its 20dp line.
120    const fn expressive_reduction(self) -> f32 {
121        match self {
122            Self::Default => 0.,
123            Self::Compact => 4.,
124            Self::Dense => 8.,
125        }
126    }
127}
128
129type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
130type Notify = Rc<dyn Fn(&mut Window, &mut App)>;
131type Select = Rc<dyn Fn(&usize, &mut Window, &mut App)>;
132type OpenChange = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
133
134/// One menu item.
135pub struct MaterialMenuItem {
136    label: SharedString,
137    supporting: Option<SharedString>,
138    leading: Option<AnyElement>,
139    trailing: Option<AnyElement>,
140    trailing_text: Option<SharedString>,
141    submenu: bool,
142    selected: bool,
143    disabled: bool,
144    divider: bool,
145    preview: Option<InteractionPreview>,
146    on_click: Option<Handler>,
147}
148
149impl MaterialMenuItem {
150    pub fn new(label: impl Into<SharedString>) -> Self {
151        Self {
152            label: label.into(),
153            supporting: None,
154            leading: None,
155            trailing: None,
156            trailing_text: None,
157            submenu: false,
158            selected: false,
159            disabled: false,
160            divider: false,
161            preview: None,
162            on_click: None,
163        }
164    }
165    pub fn supporting_text(mut self, text: impl Into<SharedString>) -> Self {
166        self.supporting = Some(text.into());
167        self
168    }
169    /// A leading icon (20dp Expressive, 24dp baseline).
170    pub fn leading(mut self, icon: impl IntoElement) -> Self {
171        self.leading = Some(icon.into_any_element());
172        self
173    }
174    /// A trailing icon.
175    pub fn trailing(mut self, icon: impl IntoElement) -> Self {
176        self.trailing = Some(icon.into_any_element());
177        self
178    }
179    /// Trailing text such as a keyboard shortcut (supplied by the caller).
180    pub fn trailing_text(mut self, text: impl Into<SharedString>) -> Self {
181        self.trailing_text = Some(text.into());
182        self
183    }
184    /// Show the sub-menu indicator; the item's activation opens the sub-menu.
185    pub fn submenu(mut self, submenu: bool) -> Self {
186        self.submenu = submenu;
187        self
188    }
189    pub fn selected(mut self, selected: bool) -> Self {
190        self.selected = selected;
191        self
192    }
193    pub fn disabled(mut self, disabled: bool) -> Self {
194        self.disabled = disabled;
195        self
196    }
197    /// Draw a divider below this item.
198    pub fn divider(mut self, divider: bool) -> Self {
199        self.divider = divider;
200        self
201    }
202    /// Render a static specimen in one forced interaction state.
203    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
204        self.preview = Some(state);
205        if state.is_disabled() {
206            self.disabled = true;
207        }
208        self
209    }
210    pub fn on_click(
211        mut self,
212        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
213    ) -> Self {
214        self.on_click = Some(Rc::new(handler));
215        self
216    }
217}
218
219/// A run of items; Expressive menus separate groups with a 2dp gap.
220pub struct MenuGroup {
221    label: Option<SharedString>,
222    items: Vec<MaterialMenuItem>,
223}
224
225impl MenuGroup {
226    pub fn new() -> Self {
227        Self {
228            label: None,
229            items: Vec::new(),
230        }
231    }
232    /// A section label above the group's items.
233    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
234        self.label = Some(label.into());
235        self
236    }
237    pub fn item(mut self, item: MaterialMenuItem) -> Self {
238        self.items.push(item);
239        self
240    }
241    pub fn items(mut self, items: impl IntoIterator<Item = MaterialMenuItem>) -> Self {
242        self.items.extend(items);
243        self
244    }
245}
246
247impl Default for MenuGroup {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253/// The roles one item paints.
254#[derive(Clone, Copy, Debug, PartialEq)]
255pub(crate) struct ItemPaint {
256    pub container: Option<Rgba>,
257    pub label: Rgba,
258    pub supporting: Rgba,
259    pub icon: Rgba,
260    pub trailing: Rgba,
261    pub layer: Rgba,
262}
263
264fn alpha(mut c: Rgba, a: f32) -> Rgba {
265    c.a *= a;
266    c
267}
268
269/// Item colors for a style and selection (disabled content fades by 38%).
270pub(crate) fn item_paint(style: MenuStyle, selected: bool) -> ItemPaint {
271    use ColorRole as C;
272    match (style, selected) {
273        (MenuStyle::Baseline, false) | (MenuStyle::Standard, false) => ItemPaint {
274            container: None,
275            label: color(C::ON_SURFACE),
276            supporting: color(C::ON_SURFACE_VARIANT),
277            icon: color(C::ON_SURFACE_VARIANT),
278            trailing: color(C::ON_SURFACE_VARIANT),
279            layer: color(C::ON_SURFACE),
280        },
281        (MenuStyle::Baseline, true) => ItemPaint {
282            container: Some(color(C::SECONDARY_CONTAINER)),
283            label: color(C::ON_SECONDARY_CONTAINER),
284            supporting: color(C::ON_SECONDARY_CONTAINER),
285            icon: color(C::ON_SURFACE_VARIANT),
286            trailing: color(C::ON_SECONDARY_CONTAINER),
287            layer: color(C::ON_SECONDARY_CONTAINER),
288        },
289        (MenuStyle::Standard, true) => ItemPaint {
290            container: Some(color(C::TERTIARY_CONTAINER)),
291            label: color(C::ON_TERTIARY_CONTAINER),
292            supporting: color(C::TERTIARY),
293            icon: color(C::ON_TERTIARY_CONTAINER),
294            trailing: color(C::ON_TERTIARY_CONTAINER),
295            layer: color(C::ON_TERTIARY_CONTAINER),
296        },
297        (MenuStyle::Vibrant, false) => {
298            let ink = color(C::ON_TERTIARY_CONTAINER);
299            ItemPaint {
300                container: None,
301                label: ink,
302                supporting: ink,
303                icon: ink,
304                trailing: ink,
305                layer: ink,
306            }
307        }
308        (MenuStyle::Vibrant, true) => {
309            let ink = color(C::ON_TERTIARY);
310            ItemPaint {
311                container: Some(color(C::TERTIARY)),
312                label: ink,
313                supporting: ink,
314                icon: ink,
315                trailing: ink,
316                layer: ink,
317            }
318        }
319    }
320}
321
322/// The menu container's color.
323pub(crate) fn container_role(style: MenuStyle) -> ColorRole {
324    match style {
325        MenuStyle::Baseline => ColorRole::SURFACE_CONTAINER,
326        MenuStyle::Standard => ColorRole::SURFACE_CONTAINER_LOW,
327        MenuStyle::Vibrant => ColorRole::TERTIARY_CONTAINER,
328    }
329}
330
331const EXPRESSIVE_ITEM: f32 = 44.;
332const EXPRESSIVE_INSET_Y: f32 = 2.;
333const EXPRESSIVE_INSET_X: f32 = 4.;
334const EXPRESSIVE_DIVIDER: f32 = 8.;
335const GROUP_LABEL: f32 = 32.;
336const GROUP_GAP: f32 = 2.;
337const GROUP_RADIUS: f32 = 8.;
338const MENU_RADIUS: f32 = 16.;
339const BASELINE_PADDING: f32 = 8.;
340const BASELINE_DIVIDER: f32 = 17.;
341const BASELINE_RADIUS: f32 = 4.;
342
343/// One item's full height in design px, including insets and a divider.
344pub(crate) fn item_height(
345    style: MenuStyle,
346    density: MenuDensity,
347    supporting: bool,
348    divider: bool,
349) -> f32 {
350    if style.expressive() {
351        let content = if supporting {
352            12. + 20. + 16. + 12.
353        } else {
354            EXPRESSIVE_ITEM
355        } - density.expressive_reduction();
356        content + 2. * EXPRESSIVE_INSET_Y + if divider { EXPRESSIVE_DIVIDER } else { 0. }
357    } else {
358        let content = if supporting {
359            (8.0_f32 + 24. + 20. + 8.).max(density.item_height())
360        } else {
361            density.item_height()
362        };
363        content + if divider { BASELINE_DIVIDER } else { 0. }
364    }
365}
366
367/// A whole menu's height in design px: one `(labelled, items)` entry per
368/// group, one `(supporting, divider)` entry per item.
369pub(crate) fn menu_height<G, I>(style: MenuStyle, density: MenuDensity, groups: G) -> f32
370where
371    G: IntoIterator<Item = (bool, I)>,
372    I: IntoIterator<Item = (bool, bool)>,
373{
374    let expressive = style.expressive();
375    let mut count = 0usize;
376    let mut total = 0.;
377    for (label, items) in groups {
378        count += 1;
379        let items: f32 = items
380            .into_iter()
381            .map(|(supporting, divider)| item_height(style, density, supporting, divider))
382            .sum();
383        let inset = if expressive {
384            2. * EXPRESSIVE_INSET_Y
385        } else {
386            0.
387        };
388        total += inset + if label { GROUP_LABEL } else { 0. } + items;
389    }
390    let between = count.saturating_sub(1) as f32;
391    if expressive {
392        total + GROUP_GAP * between
393    } else {
394        2. * BASELINE_PADDING + total + BASELINE_DIVIDER * between
395    }
396}
397
398/// Focus handles for a menu's root and items, kept across renders.
399struct MenuFocus {
400    root: FocusHandle,
401    items: Vec<FocusHandle>,
402}
403
404/// An M3 menu surface. Use [`MenuPopup`] to open it from a trigger.
405#[derive(gpui::IntoElement)]
406pub struct MaterialMenu {
407    id: ElementId,
408    style: MenuStyle,
409    density: MenuDensity,
410    groups: Vec<MenuGroup>,
411    width: Option<f32>,
412    elevated: bool,
413    focus: Option<FocusHandle>,
414    on_dismiss: Option<Notify>,
415    on_activate: Option<Notify>,
416    on_collapse: Option<Notify>,
417    focus_item: Option<usize>,
418}
419
420impl MaterialMenu {
421    pub fn new(id: impl Into<ElementId>) -> Self {
422        Self {
423            id: id.into(),
424            style: MenuStyle::Standard,
425            density: MenuDensity::Default,
426            groups: Vec::new(),
427            width: None,
428            elevated: true,
429            focus: None,
430            on_dismiss: None,
431            on_activate: None,
432            on_collapse: None,
433            focus_item: None,
434        }
435    }
436    pub fn style(mut self, style: MenuStyle) -> Self {
437        self.style = style;
438        self
439    }
440    /// Item density: the kit's baseline densities, and half those steps for
441    /// Expressive items.
442    pub fn density(mut self, density: MenuDensity) -> Self {
443        self.density = density;
444        self
445    }
446    /// Append an item to the last group.
447    pub fn item(mut self, item: MaterialMenuItem) -> Self {
448        if self.groups.is_empty() {
449            self.groups.push(MenuGroup::new());
450        }
451        self.groups.last_mut().unwrap().items.push(item);
452        self
453    }
454    pub fn items(mut self, items: impl IntoIterator<Item = MaterialMenuItem>) -> Self {
455        for item in items {
456            self = self.item(item);
457        }
458        self
459    }
460    pub fn group(mut self, group: MenuGroup) -> Self {
461        self.groups.push(group);
462        self
463    }
464    /// Width in design px (default 200 baseline, 208 Expressive).
465    pub fn width(mut self, width: f32) -> Self {
466        self.width = Some(width);
467        self
468    }
469    /// Paint the menu's level 2 shadow (default). A popup paints it on its
470    /// revealing frame instead.
471    pub fn elevated(mut self, elevated: bool) -> Self {
472        self.elevated = elevated;
473        self
474    }
475    /// The handle the menu root tracks; arrow keys from it reach the items.
476    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
477        self.focus = Some(focus.clone());
478        self
479    }
480    /// Escape inside the menu.
481    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
482        self.on_dismiss = Some(Rc::new(handler));
483        self
484    }
485    /// Runs after any enabled item (other than a sub-menu) is activated.
486    pub fn on_activate(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
487        self.on_activate = Some(Rc::new(handler));
488        self
489    }
490    /// Focus the item at flat `index` this frame, e.g. a sub-menu's owner as
491    /// the sub-menu closes.
492    pub(crate) fn focus_item(mut self, index: usize) -> Self {
493        self.focus_item = Some(index);
494        self
495    }
496    /// Left inside the menu, e.g. to close it as a cascading sub-menu.
497    pub fn on_collapse(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
498        self.on_collapse = Some(Rc::new(handler));
499        self
500    }
501
502    /// The menu's natural height in design px.
503    pub fn natural_height(&self) -> f32 {
504        menu_height(
505            self.style,
506            self.density,
507            self.groups.iter().map(|group| {
508                (
509                    group.label.is_some(),
510                    group
511                        .items
512                        .iter()
513                        .map(|item| (item.supporting.is_some(), item.divider)),
514                )
515            }),
516        )
517    }
518
519    /// The bottom of the item at flat `index` (its row without a divider
520    /// below it), in design px from the menu's top.
521    pub fn item_bottom(&self, index: usize) -> Option<f32> {
522        let item = self.groups.iter().flat_map(|g| &g.items).nth(index)?;
523        let height = item_height(self.style, self.density, item.supporting.is_some(), false);
524        Some(self.item_top(index)? + height)
525    }
526
527    /// The top of the item at flat `index` (its row, with an Expressive
528    /// row's insets), in design px from the menu's top.
529    pub fn item_top(&self, index: usize) -> Option<f32> {
530        let expressive = self.style.expressive();
531        let mut top = if expressive { 0. } else { BASELINE_PADDING };
532        let mut flat = 0;
533        for (group_ix, group) in self.groups.iter().enumerate() {
534            if group_ix > 0 {
535                top += if expressive {
536                    GROUP_GAP
537                } else {
538                    BASELINE_DIVIDER
539                };
540            }
541            if expressive {
542                top += EXPRESSIVE_INSET_Y;
543            }
544            if group.label.is_some() {
545                top += GROUP_LABEL;
546            }
547            for item in &group.items {
548                if flat == index {
549                    return Some(top);
550                }
551                top += item_height(
552                    self.style,
553                    self.density,
554                    item.supporting.is_some(),
555                    item.divider,
556                );
557                flat += 1;
558            }
559            if expressive {
560                top += EXPRESSIVE_INSET_Y;
561            }
562        }
563        None
564    }
565}
566
567impl RenderOnce for MaterialMenu {
568    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
569        let style = self.style;
570        let density = self.density;
571        let count: usize = self.groups.iter().map(|g| g.items.len()).sum();
572        let state =
573            window.use_keyed_state((self.id.clone(), "menu-focus"), cx, |_, cx| MenuFocus {
574                root: cx.focus_handle(),
575                items: Vec::new(),
576            });
577        state.update(cx, |state, cx| {
578            while state.items.len() < count {
579                state.items.push(cx.focus_handle());
580            }
581            state.items.truncate(count);
582        });
583        let root_focus = self
584            .focus
585            .clone()
586            .unwrap_or_else(|| state.read(cx).root.clone());
587        let handles = state.read(cx).items.clone();
588        if let Some(handle) = self.focus_item.and_then(|ix| handles.get(ix)) {
589            handle.focus(window, cx);
590        }
591        let skip: Rc<[bool]> = self
592            .groups
593            .iter()
594            .flat_map(|g| g.items.iter().map(|i| i.disabled || i.preview.is_some()))
595            .collect();
596        // Enter and Space activate the focused item (the popover's content
597        // context reserves them, so the menu answers them itself).
598        let activators: Rc<Vec<Option<(Handler, bool)>>> = Rc::new(
599            self.groups
600                .iter()
601                .flat_map(|g| {
602                    g.items.iter().map(|i| {
603                        i.on_click
604                            .clone()
605                            .filter(|_| !i.disabled && i.preview.is_none())
606                            .map(|h| (h, i.submenu))
607                    })
608                })
609                .collect(),
610        );
611        // Right opens a focused sub-menu item: it activates the item, whose
612        // handler opens the sub-menu.
613        let expand = {
614            let handles = handles.clone();
615            let activators = activators.clone();
616            move |window: &mut Window, cx: &mut App| {
617                let focused = handles.iter().position(|h| h.is_focused(window));
618                match focused.and_then(|ix| activators.get(ix)) {
619                    Some(Some((handler, true))) => {
620                        let event = ClickEvent::Keyboard(KeyboardClickEvent {
621                            button: KeyboardButton::Enter,
622                            bounds: Bounds::default(),
623                        });
624                        handler(&event, window, cx);
625                    }
626                    _ => cx.propagate(),
627                }
628            }
629        };
630        let on_collapse = self.on_collapse.clone();
631        let activate = {
632            let handles = handles.clone();
633            let activators = activators.clone();
634            let on_activate = self.on_activate.clone();
635            move |window: &mut Window, cx: &mut App| {
636                let Some(ix) = handles.iter().position(|h| h.is_focused(window)) else {
637                    return;
638                };
639                if let Some(Some((handler, submenu))) = activators.get(ix) {
640                    let event = ClickEvent::Keyboard(KeyboardClickEvent {
641                        button: KeyboardButton::Enter,
642                        bounds: Bounds::default(),
643                    });
644                    handler(&event, window, cx);
645                    if !submenu && let Some(done) = on_activate.as_ref() {
646                        done(window, cx);
647                    }
648                }
649            }
650        };
651        let navigate = {
652            let handles = handles.clone();
653            let skip = skip.clone();
654            move |direction: Direction, window: &mut Window, cx: &mut App| {
655                let current = handles.iter().position(|h| h.is_focused(window));
656                if let Some(ix) = target(&skip, current, direction) {
657                    handles[ix].focus(window, cx);
658                }
659            }
660        };
661        let width = self
662            .width
663            .unwrap_or(if style.expressive() { 208. } else { 200. });
664        let container = color(container_role(style));
665        let on_activate = self.on_activate.clone();
666        let group_count = self.groups.len();
667        let mut index = 0usize;
668        let mut groups = Vec::with_capacity(group_count);
669        for (group_ix, group) in self.groups.into_iter().enumerate() {
670            let mut items = Vec::with_capacity(group.items.len());
671            for item in group.items {
672                items.push(render_item(
673                    // Scoped by the menu container's own id below, so a
674                    // static name is unique without formatting per frame.
675                    ("material-menu-item", index),
676                    item,
677                    style,
678                    density,
679                    handles.get(index).cloned(),
680                    on_activate.clone(),
681                    window.rem_size(),
682                ));
683                index += 1;
684            }
685            let label = group.label.map(|label| {
686                let ink = if style == MenuStyle::Vibrant {
687                    color(ColorRole::ON_TERTIARY_CONTAINER)
688                } else {
689                    color(ColorRole::ON_SURFACE_VARIANT)
690                };
691                type_style(div(), TypeRole::LabelLarge)
692                    .h(rem(GROUP_LABEL))
693                    .flex()
694                    .items_center()
695                    .pt(rem(6.))
696                    .px(rem(12. + EXPRESSIVE_INSET_X))
697                    .text_color(ink)
698                    .child(label)
699            });
700            let group_el = if style.expressive() {
701                let top = if group_ix == 0 {
702                    MENU_RADIUS
703                } else {
704                    GROUP_RADIUS
705                };
706                let bottom = if group_ix + 1 == group_count {
707                    MENU_RADIUS
708                } else {
709                    GROUP_RADIUS
710                };
711                div()
712                    .flex()
713                    .flex_col()
714                    .w_full()
715                    .py(rem(EXPRESSIVE_INSET_Y))
716                    .bg(container)
717                    .rounded_t(rem(top))
718                    .rounded_b(rem(bottom))
719                    .children(label)
720                    .children(items)
721            } else {
722                div()
723                    .flex()
724                    .flex_col()
725                    .w_full()
726                    .when(group_ix > 0, |this| this.child(divider_line(false)))
727                    .children(label)
728                    .children(items)
729            };
730            groups.push(group_el);
731        }
732        let on_dismiss = self.on_dismiss.clone();
733        let nav_up = navigate.clone();
734        let nav_down = navigate.clone();
735        let nav_first = navigate.clone();
736        let nav_last = navigate;
737        div()
738            .id(self.id.clone())
739            .debug_selector(|| "material-menu".into())
740            .role(Role::Menu)
741            .key_context(MENU_CONTEXT)
742            .track_focus(&root_focus)
743            .on_action(move |_: &SelectUp, window, cx| nav_up(Direction::Previous, window, cx))
744            .on_action(move |_: &SelectDown, window, cx| nav_down(Direction::Next, window, cx))
745            .on_action(move |_: &SelectFirst, window, cx| nav_first(Direction::First, window, cx))
746            .on_action(move |_: &SelectLast, window, cx| nav_last(Direction::Last, window, cx))
747            .on_action(move |_: &Confirm, window, cx| activate(window, cx))
748            .on_action(move |_: &SelectRight, window, cx| expand(window, cx))
749            .on_action(move |_: &SelectLeft, window, cx| {
750                if let Some(collapse) = on_collapse.as_ref() {
751                    collapse(window, cx);
752                } else {
753                    cx.propagate();
754                }
755            })
756            .on_action(move |_: &Cancel, window, cx| {
757                if let Some(dismiss) = on_dismiss.as_ref() {
758                    dismiss(window, cx);
759                } else {
760                    cx.propagate();
761                }
762            })
763            .flex()
764            .flex_col()
765            .flex_shrink_0()
766            .w(rem(width))
767            .map(|this| {
768                if style.expressive() {
769                    this.gap(rem(GROUP_GAP)).rounded(rem(MENU_RADIUS))
770                } else {
771                    this.py(rem(BASELINE_PADDING))
772                        .rounded(rem(BASELINE_RADIUS))
773                        .bg(container)
774                }
775            })
776            .when(self.elevated, |this| this.shadow(elevation::level(2)))
777            .children(groups)
778    }
779}
780
781fn divider_line(expressive: bool) -> Div {
782    let line = div()
783        .w_full()
784        .h(px(1.))
785        .bg(color(ColorRole::OUTLINE_VARIANT));
786    if expressive {
787        div()
788            .w_full()
789            .px(rem(8. + EXPRESSIVE_INSET_X))
790            .py(rem(2.))
791            .mt(rem(4.))
792            .child(line)
793    } else {
794        div().w_full().py(rem(8.)).child(line)
795    }
796}
797
798/// The sub-menu indicator, `arrow_right` from the Material Symbols catalog.
799fn submenu_icon(size: f32) -> Icon {
800    Icon::from(Symbol::named("arrow_right").expect("arrow_right is a Material Symbol"))
801        .size(rem(size))
802}
803
804fn render_item(
805    id: impl Into<ElementId>,
806    item: MaterialMenuItem,
807    style: MenuStyle,
808    density: MenuDensity,
809    focus: Option<FocusHandle>,
810    on_activate: Option<Notify>,
811    rem_size: Pixels,
812) -> AnyElement {
813    let expressive = style.expressive();
814    let paint = item_paint(style, item.selected);
815    let disabled = item.disabled;
816    let fade = |c: Rgba| {
817        if disabled {
818            alpha(c, state::DISABLED_CONTENT)
819        } else {
820            c
821        }
822    };
823    let icon_size = if expressive { 20. } else { 24. };
824    let radius = if expressive {
825        if item.selected { 12. } else { 4. }
826    } else {
827        0.
828    };
829    // The ring and ripple share the item container's rem-sized corners.
830    let corners = Corners::all(rem(radius).to_pixels(rem_size));
831    let content_height = item_height(style, density, item.supporting.is_some(), false)
832        - if expressive {
833            2. * EXPRESSIVE_INSET_Y
834        } else {
835            0.
836        };
837    let layer = paint.layer;
838    // A disabled selected standard item keeps its shape but drops its
839    // container; a vibrant one keeps its tertiary container.
840    let container = paint
841        .container
842        .filter(|_| !(disabled && style == MenuStyle::Standard));
843    // State layers composite over the item's own container.
844    let under = container.unwrap_or(alpha(layer, 0.));
845    let slot = |child: AnyElement, ink: Rgba| {
846        div()
847            .flex()
848            .flex_shrink_0()
849            .items_center()
850            .justify_center()
851            .size(rem(icon_size))
852            .text_color(ink)
853            .child(child)
854    };
855    let label_role = if expressive {
856        TypeRole::LabelLarge
857    } else {
858        TypeRole::BodyLarge
859    };
860    let supporting_role = if expressive {
861        TypeRole::BodySmall
862    } else {
863        TypeRole::BodyMedium
864    };
865    let trailing_icon = item.trailing.or_else(|| {
866        item.submenu
867            .then(|| submenu_icon(icon_size).into_any_element())
868    });
869    let mut button = RingedButton::new(id, RingPlacement::Inner(corners))
870        .state_layers(
871            state::layer(under, layer, state::HOVER),
872            state::layer(under, layer, state::PRESS),
873        )
874        .disabled(disabled)
875        .accessibility_label(item.label.clone())
876        .map(|b| {
877            b.role(Role::MenuItem)
878                .flex()
879                .items_center()
880                .w_full()
881                .h(rem(content_height))
882                .px(rem(12.))
883                .gap(rem(if expressive { 8. } else { 12. }))
884                .rounded(rem(radius))
885                .when(!disabled, |b| b.cursor_pointer())
886        })
887        .when_some(container, |b, c| b.bg(c))
888        .children(item.leading.map(|icon| slot(icon, fade(paint.icon))))
889        .child(
890            div()
891                .flex()
892                .flex_col()
893                .flex_1()
894                .min_w_0()
895                .child(
896                    type_style(div(), label_role)
897                        .text_color(fade(paint.label))
898                        .whitespace_nowrap()
899                        .overflow_hidden()
900                        .child(item.label),
901                )
902                .children(item.supporting.map(|text| {
903                    type_style(div(), supporting_role)
904                        .text_color(fade(paint.supporting))
905                        .whitespace_nowrap()
906                        .overflow_hidden()
907                        .child(text)
908                })),
909        )
910        .children(item.trailing_text.map(|text| {
911            type_style(div().flex_shrink_0(), TypeRole::LabelLarge)
912                .text_color(fade(paint.trailing))
913                .child(text)
914        }))
915        .children(trailing_icon.map(|icon| slot(icon, fade(paint.trailing))));
916    if let Some(preview) = item.preview {
917        button = button.preview_state(preview);
918        if preview.is_focused() {
919            // The kit paints the focus layer as well as the ring.
920            button = button.map(|b| b.bg(state::layer(under, layer, state::FOCUS)));
921        }
922    } else if let Some(focus) = focus {
923        button = button.track_focus(&focus);
924    }
925    if let Some(handler) = item
926        .on_click
927        .filter(|_| !disabled && item.preview.is_none())
928    {
929        let submenu = item.submenu;
930        button = button.on_click(move |event, window, cx| {
931            handler(event, window, cx);
932            if !submenu && let Some(activate) = on_activate.as_ref() {
933                activate(window, cx);
934            }
935        });
936    }
937    let row = if expressive {
938        div()
939            .w_full()
940            .px(rem(EXPRESSIVE_INSET_X))
941            .py(rem(EXPRESSIVE_INSET_Y))
942            .child(button)
943    } else {
944        div().w_full().child(button)
945    };
946    if item.divider {
947        div()
948            .w_full()
949            .child(row)
950            .child(divider_line(expressive).when(expressive, |d| d.mt(rem(0.))))
951            .into_any_element()
952    } else {
953        row.into_any_element()
954    }
955}
956
957/// The open progress `(reveal, fade)` a popup paints this frame.
958fn open_motion(id: &ElementId, open: bool, window: &mut Window, cx: &mut App) -> (f32, f32) {
959    let target = if open { 1. } else { 0. };
960    let reveal = motion::token_value(
961        (id.clone(), "menu-reveal"),
962        target,
963        MotionToken::DefaultSpatial,
964        window,
965        cx,
966    );
967    let fade = motion::token_value(
968        (id.clone(), "menu-fade"),
969        target,
970        MotionToken::FastEffects,
971        window,
972        cx,
973    )
974    .clamp(0., 1.);
975    (reveal, fade)
976}
977
978/// Whether a popup still paints its surface: open, or closing until both
979/// channels settle.
980pub(crate) fn popup_visible(open: bool, reveal: f32, fade: f32) -> bool {
981    open || reveal > 0.001 || fade > 0.001
982}
983
984#[derive(gpui::IntoElement)]
985struct Trigger {
986    child: AnyElement,
987    open: bool,
988}
989
990impl gpui_kit::component::Selectable for Trigger {
991    fn selected(mut self, selected: bool) -> Self {
992        self.open = selected;
993        self
994    }
995    fn is_selected(&self) -> bool {
996        self.open
997    }
998}
999
1000impl RenderOnce for Trigger {
1001    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
1002        self.child
1003    }
1004}
1005
1006/// A trigger that opens a [`MaterialMenu`] in Kit's popover overlay.
1007///
1008/// `open` is controlled: Kit's popover reports pointer toggles, outside
1009/// clicks and Escape through `on_open_change`, and an activated item closes
1010/// the menu the same way (unless [`Self::close_on_activate`] is off).
1011/// Keyboard activation of the trigger itself is the caller's (e.g. its
1012/// button's click handler).
1013///
1014/// A cascading sub-menu ([`Self::submenu`]) opens beside the item that owns
1015/// it, on the side away from the popup's anchor edge, with the same reveal
1016/// motion. Right on a sub-menu item activates it (the caller opens the
1017/// sub-menu, which takes focus); Left or Escape inside the sub-menu reports
1018/// `false` through [`Self::on_submenu_change`] and returns focus to the
1019/// parent menu.
1020#[derive(gpui::IntoElement)]
1021pub struct MenuPopup {
1022    id: ElementId,
1023    trigger: AnyElement,
1024    menu: MaterialMenu,
1025    open: bool,
1026    on_open_change: Option<OpenChange>,
1027    anchor: Anchor,
1028    close_on_activate: bool,
1029    submenu: Option<(usize, MaterialMenu)>,
1030    submenu_open: bool,
1031    on_submenu_change: Option<OpenChange>,
1032}
1033
1034impl MenuPopup {
1035    pub fn new(id: impl Into<ElementId>, trigger: impl IntoElement, menu: MaterialMenu) -> Self {
1036        Self {
1037            id: id.into(),
1038            trigger: trigger.into_any_element(),
1039            menu,
1040            open: false,
1041            on_open_change: None,
1042            anchor: Anchor::TopLeft,
1043            close_on_activate: true,
1044            submenu: None,
1045            submenu_open: false,
1046            on_submenu_change: None,
1047        }
1048    }
1049    /// Close the menu when an item (other than a sub-menu) is activated
1050    /// (default). Off, the menu stays open, e.g. to toggle several items.
1051    pub fn close_on_activate(mut self, close: bool) -> Self {
1052        self.close_on_activate = close;
1053        self
1054    }
1055    /// A cascading sub-menu for the main menu's item at flat `index`
1056    /// (counting across groups), shown while `open`.
1057    pub fn submenu(mut self, index: usize, menu: MaterialMenu, open: bool) -> Self {
1058        self.submenu = Some((index, menu));
1059        self.submenu_open = open;
1060        self
1061    }
1062    /// Left or Escape inside the open sub-menu reports `false`.
1063    pub fn on_submenu_change(
1064        mut self,
1065        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
1066    ) -> Self {
1067        self.on_submenu_change = Some(Rc::new(handler));
1068        self
1069    }
1070    pub fn open(mut self, open: bool) -> Self {
1071        self.open = open;
1072        self
1073    }
1074    pub fn on_open_change(
1075        mut self,
1076        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
1077    ) -> Self {
1078        self.on_open_change = Some(Rc::new(handler));
1079        self
1080    }
1081    /// The popup's own anchor corner (Kit's convention; default top-left,
1082    /// below the trigger). `LeftCenter` and `RightCenter` place the menu
1083    /// beside the trigger, to its right or left, with their tops aligned
1084    /// (M3's "right start" and "left start" placements).
1085    pub fn anchor(mut self, anchor: Anchor) -> Self {
1086        self.anchor = anchor;
1087        self
1088    }
1089}
1090
1091/// Whether a popup anchored at `anchor` sits above its trigger, so it
1092/// reveals from its bottom edge.
1093pub(crate) fn opens_upward(anchor: Anchor) -> bool {
1094    matches!(
1095        anchor,
1096        Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight
1097    )
1098}
1099
1100/// Whether a sub-menu cascades to the left: popups anchored on their right
1101/// edge (end placements, or beside the trigger's left) open leftward.
1102pub(crate) fn cascades_left(anchor: Anchor) -> bool {
1103    matches!(
1104        anchor,
1105        Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter
1106    )
1107}
1108
1109/// The space between a menu and its cascading sub-menu.
1110const CASCADE_GAP: f32 = 4.;
1111
1112/// A menu's revealing frame: `height * shown` tall, its shadow and opacity
1113/// following `fade`, revealing from the top (or the bottom when `upward`).
1114fn reveal_surface(
1115    menu: impl IntoElement,
1116    style: MenuStyle,
1117    height: f32,
1118    (shown, fade): (f32, f32),
1119    upward: bool,
1120) -> Div {
1121    let radius = if style.expressive() {
1122        MENU_RADIUS
1123    } else {
1124        BASELINE_RADIUS
1125    };
1126    let ring = fade * fade * fade;
1127    let shadow: Vec<BoxShadow> = elevation::level(2)
1128        .into_iter()
1129        .map(|mut s| {
1130            s.color.a *= ring;
1131            s
1132        })
1133        .collect();
1134    div()
1135        .flex()
1136        .flex_col()
1137        .when(upward, |surface| surface.justify_end())
1138        .flex_shrink_0()
1139        .h(rem(height * shown.clamp(0., 1.)))
1140        .overflow_hidden()
1141        .rounded(rem(radius))
1142        .shadow(shadow)
1143        .opacity(fade)
1144        .child(menu)
1145}
1146
1147impl RenderOnce for MenuPopup {
1148    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1149        let (reveal, fade) = open_motion(&self.id, self.open, window, cx);
1150        let visible = popup_visible(self.open, reveal, fade);
1151        let focus = window
1152            .use_keyed_state((self.id.clone(), "menu-popup-focus"), cx, |_, cx| {
1153                cx.focus_handle()
1154            })
1155            .read(cx)
1156            .clone();
1157        // The trigger's height in design px, measured as it prepaints,
1158        // places a side popup's top at the trigger's top.
1159        let trigger_height = window
1160            .use_keyed_state((self.id.clone(), "menu-popup-trigger"), cx, |_, _| {
1161                Rc::new(std::cell::Cell::new(0f32))
1162            })
1163            .read(cx)
1164            .clone();
1165        let style = self.menu.style;
1166        let height = self.menu.natural_height();
1167        let anchor = self.anchor;
1168        let upward = opens_upward(anchor);
1169        let side = matches!(anchor, Anchor::LeftCenter | Anchor::RightCenter);
1170        let change = self.on_open_change.clone();
1171        let close = self.on_open_change.clone();
1172        let close_on_activate = self.close_on_activate;
1173        let close_menu: Notify = Rc::new(move |window: &mut Window, cx: &mut App| {
1174            if let Some(close) = close.as_ref() {
1175                close(&false, window, cx);
1176            }
1177        });
1178        let submenu_index = self.submenu.as_ref().map(|(index, _)| *index);
1179        let submenu_top = submenu_index.and_then(|index| self.menu.item_top(index));
1180        let submenu_bottom = submenu_index.and_then(|index| self.menu.item_bottom(index));
1181        // The reserved cascade column's height below the trigger.
1182        let cascade_height = self.submenu.as_ref().map_or(0., |(_, submenu)| {
1183            let first_row = if submenu.style.expressive() {
1184                EXPRESSIVE_INSET_Y
1185            } else {
1186                BASELINE_PADDING
1187            };
1188            (submenu_top.unwrap_or(0.) - first_row).max(0.) + submenu.natural_height()
1189        });
1190        // Closing the sub-menu by keyboard returns focus to its owner item.
1191        let restore = window
1192            .use_keyed_state((self.id.clone(), "menu-submenu-restore"), cx, |_, _| {
1193                Rc::new(std::cell::Cell::new(false))
1194            })
1195            .read(cx)
1196            .clone();
1197        let mut menu = self.menu;
1198        if let Some(index) = submenu_index
1199            && restore.replace(false)
1200        {
1201            menu = menu.focus_item(index);
1202        }
1203        let menu = menu
1204            .elevated(false)
1205            .track_focus(&focus)
1206            .on_dismiss({
1207                let close = close_menu.clone();
1208                move |window, cx| close(window, cx)
1209            })
1210            .on_activate({
1211                let close = close_menu.clone();
1212                move |window, cx| {
1213                    if close_on_activate {
1214                        close(window, cx)
1215                    }
1216                }
1217            });
1218        let shown = reveal.clamp(0., 1.);
1219        let surface = reveal_surface(menu, style, height, (shown, fade), upward)
1220            .debug_selector(|| "menu-popup-surface".into());
1221
1222        // The cascading sub-menu, revealed on its own channels.
1223        let cascade = self.submenu.map(|(_, submenu)| {
1224            let open = self.open && self.submenu_open;
1225            let cascade_id = ElementId::from((self.id.clone(), "submenu"));
1226            let (sub_reveal, sub_fade) = open_motion(&cascade_id, open, window, cx);
1227            let sub_focus = window
1228                .use_keyed_state((self.id.clone(), "menu-submenu-focus"), cx, |_, cx| {
1229                    cx.focus_handle()
1230                })
1231                .read(cx)
1232                .clone();
1233            // The sub-menu takes focus as it opens.
1234            let was_open = window
1235                .use_keyed_state((self.id.clone(), "menu-submenu-open"), cx, |_, _| {
1236                    Rc::new(std::cell::Cell::new(false))
1237                })
1238                .read(cx)
1239                .clone();
1240            if open && !was_open.get() {
1241                sub_focus.focus(window, cx);
1242            }
1243            was_open.set(open);
1244            let sub_style = submenu.style;
1245            let sub_height = submenu.natural_height();
1246            let collapse: Notify = {
1247                let change = self.on_submenu_change.clone();
1248                let parent = focus.clone();
1249                let restore = restore.clone();
1250                Rc::new(move |window: &mut Window, cx: &mut App| {
1251                    parent.focus(window, cx);
1252                    restore.set(true);
1253                    if let Some(change) = change.as_ref() {
1254                        change(&false, window, cx);
1255                    }
1256                })
1257            };
1258            let submenu = submenu
1259                .elevated(false)
1260                .track_focus(&sub_focus)
1261                .on_dismiss({
1262                    let collapse = collapse.clone();
1263                    move |window, cx| collapse(window, cx)
1264                })
1265                .on_collapse(move |window, cx| collapse(window, cx))
1266                .on_activate({
1267                    let close = close_menu.clone();
1268                    move |window, cx| {
1269                        if close_on_activate {
1270                            close(window, cx)
1271                        }
1272                    }
1273                });
1274            let first_row = if sub_style.expressive() {
1275                EXPRESSIVE_INSET_Y
1276            } else {
1277                BASELINE_PADDING
1278            };
1279            let visible = popup_visible(open, sub_reveal, sub_fade);
1280            // The column is reserved while closed, so opening the sub-menu
1281            // never moves the menu Kit anchored. Below the trigger the
1282            // sub-menu's first item lines up with its owner and it extends
1283            // down; above the trigger its last item lines up with the
1284            // owner's bottom and it extends up, within the popup's box.
1285            let column = div().flex_shrink_0().flex().flex_col();
1286            let column = if upward {
1287                let under = (height - submenu_bottom.unwrap_or(height) - first_row).max(0.);
1288                column.h(rem(sub_height + under)).pb(rem(under))
1289            } else {
1290                let top = (submenu_top.unwrap_or(0.) - first_row).max(0.);
1291                column.h(rem(top + sub_height)).pt(rem(top))
1292            };
1293            column.when(!visible, |cascade| cascade.invisible()).child(
1294                reveal_surface(
1295                    submenu,
1296                    sub_style,
1297                    sub_height,
1298                    (sub_reveal.clamp(0., 1.), sub_fade),
1299                    false,
1300                )
1301                .debug_selector(|| "menu-popup-submenu".into()),
1302            )
1303        });
1304        // Kit centers a side popup on the trigger; the margin moves the
1305        // menu's top to the trigger's top.
1306        let side_offset = if side {
1307            ((height * shown).max(cascade_height) - trigger_height.get()).max(0.)
1308        } else {
1309            0.
1310        };
1311        let content = div()
1312            .flex()
1313            .map(|content| {
1314                if upward {
1315                    content.items_end()
1316                } else {
1317                    content.items_start()
1318                }
1319            })
1320            .gap(rem(CASCADE_GAP))
1321            .when(cascades_left(anchor), |content| content.flex_row_reverse())
1322            .when(side_offset > 0., |content| content.mt(rem(side_offset)))
1323            .child(surface)
1324            .children(cascade);
1325        let surface = std::cell::RefCell::new(Some(content.into_any_element()));
1326        let open = self.open;
1327        let measure = trigger_height;
1328        // Material buttons block the mouse for the elements under them, so
1329        // Kit's popover never sees a press on such a trigger. A window
1330        // listener over the trigger's bounds toggles the menu instead (a
1331        // press while open closes it before the popover's outside-press
1332        // dismissal, which then has nothing to do).
1333        let toggle = self.on_open_change.clone();
1334        let press = canvas(
1335            |_, _, _| {},
1336            move |bounds, _, window, _| {
1337                let toggle = toggle.clone();
1338                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
1339                    if phase == DispatchPhase::Capture
1340                        && event.button == MouseButton::Left
1341                        && bounds.contains(&event.position)
1342                        && let Some(toggle) = toggle.as_ref()
1343                    {
1344                        toggle(&!open, window, cx);
1345                    }
1346                });
1347            },
1348        )
1349        .absolute()
1350        .size_full();
1351        let trigger = div()
1352            .relative()
1353            .child(press)
1354            .on_children_prepainted(move |bounds, window, _| {
1355                if let Some(bounds) = bounds.first() {
1356                    measure.set(
1357                        f32::from(bounds.size.height) * crate::tokens::typography::BASE_PX
1358                            / f32::from(window.rem_size()),
1359                    );
1360                }
1361            })
1362            .child(self.trigger);
1363        Popover::new(self.id.clone())
1364            .appearance(false)
1365            .anchor(anchor)
1366            .offset(rem(4.).to_pixels(window.rem_size()))
1367            .track_focus(&focus)
1368            .open(visible)
1369            .on_open_change(move |next, window, cx| {
1370                if let Some(change) = change.as_ref()
1371                    && *next != open
1372                {
1373                    change(next, window, cx);
1374                }
1375            })
1376            .trigger(Trigger {
1377                child: trigger.into_any_element(),
1378                open: self.open,
1379            })
1380            .content(move |_, _, _| {
1381                surface
1382                    .borrow_mut()
1383                    .take()
1384                    .unwrap_or_else(|| div().into_any_element())
1385            })
1386    }
1387}
1388
1389/// The M3 exposed dropdown menu: a text field that opens a menu of options.
1390#[derive(gpui::IntoElement)]
1391pub struct ExposedDropdown {
1392    id: ElementId,
1393    variant: TextFieldVariant,
1394    density: TextFieldDensity,
1395    label: SharedString,
1396    options: Vec<SharedString>,
1397    disabled_options: Vec<usize>,
1398    selected: Option<usize>,
1399    open: bool,
1400    disabled: bool,
1401    error: bool,
1402    leading: Option<AnyElement>,
1403    supporting: Option<SharedString>,
1404    style: MenuStyle,
1405    width: f32,
1406    on_open_change: Option<OpenChange>,
1407    on_select: Option<Select>,
1408}
1409
1410impl ExposedDropdown {
1411    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
1412        Self {
1413            id: id.into(),
1414            variant: TextFieldVariant::Filled,
1415            density: TextFieldDensity::Default,
1416            label: label.into(),
1417            options: Vec::new(),
1418            disabled_options: Vec::new(),
1419            selected: None,
1420            open: false,
1421            disabled: false,
1422            error: false,
1423            leading: None,
1424            supporting: None,
1425            style: MenuStyle::Standard,
1426            width: 210.,
1427            on_open_change: None,
1428            on_select: None,
1429        }
1430    }
1431    pub fn variant(mut self, variant: TextFieldVariant) -> Self {
1432        self.variant = variant;
1433        self
1434    }
1435    /// The field's density (default 56dp; compact 48dp).
1436    pub fn density(mut self, density: TextFieldDensity) -> Self {
1437        self.density = density;
1438        self
1439    }
1440    pub fn options<S: Into<SharedString>>(mut self, options: impl IntoIterator<Item = S>) -> Self {
1441        self.options = options.into_iter().map(Into::into).collect();
1442        self
1443    }
1444    /// Options (by index) shown disabled: they cannot be chosen.
1445    pub fn disabled_options(mut self, options: impl IntoIterator<Item = usize>) -> Self {
1446        self.disabled_options = options.into_iter().collect();
1447        self
1448    }
1449    /// Paint the field's error colors (with the supporting text as the
1450    /// error message).
1451    pub fn error(mut self, error: bool) -> Self {
1452        self.error = error;
1453        self
1454    }
1455    pub fn selected(mut self, selected: Option<usize>) -> Self {
1456        self.selected = selected;
1457        self
1458    }
1459    pub fn open(mut self, open: bool) -> Self {
1460        self.open = open;
1461        self
1462    }
1463    pub fn disabled(mut self, disabled: bool) -> Self {
1464        self.disabled = disabled;
1465        self
1466    }
1467    pub fn leading_icon(mut self, icon: impl IntoElement) -> Self {
1468        self.leading = Some(icon.into_any_element());
1469        self
1470    }
1471    pub fn supporting_text(mut self, text: impl Into<SharedString>) -> Self {
1472        self.supporting = Some(text.into());
1473        self
1474    }
1475    pub fn menu_style(mut self, style: MenuStyle) -> Self {
1476        self.style = style;
1477        self
1478    }
1479    /// Field and menu width in design px (default 210).
1480    pub fn width(mut self, width: f32) -> Self {
1481        self.width = width;
1482        self
1483    }
1484    pub fn on_open_change(
1485        mut self,
1486        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
1487    ) -> Self {
1488        self.on_open_change = Some(Rc::new(handler));
1489        self
1490    }
1491    pub fn on_select(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
1492        self.on_select = Some(Rc::new(handler));
1493        self
1494    }
1495}
1496
1497impl RenderOnce for ExposedDropdown {
1498    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1499        let id = self.id.clone();
1500        let focus = window
1501            .use_keyed_state((id.clone(), "dropdown-focus"), cx, |_, cx| {
1502                cx.focus_handle().tab_stop(true)
1503            })
1504            .read(cx)
1505            .clone();
1506        let open = self.open && !self.disabled;
1507        let keyboard_focus = focus.is_focused(window) && window.last_input_was_keyboard();
1508        let value = self
1509            .selected
1510            .and_then(|ix| self.options.get(ix).cloned())
1511            .unwrap_or_default();
1512        // The arrow turns over with the menu on the fast spatial token.
1513        let turn = motion::token_value(
1514            (id.clone(), "dropdown-arrow"),
1515            if open { 1. } else { 0. },
1516            MotionToken::FastSpatial,
1517            window,
1518            cx,
1519        );
1520        let arrow = Icon::from(
1521            Symbol::named("arrow_drop_down").expect("arrow_drop_down is a Material Symbol"),
1522        )
1523        .size(rem(24.))
1524        .rotate(std::f32::consts::PI * turn);
1525        let mut field = TextField::specimen((id.clone(), "field"), value.clone())
1526            .variant(self.variant)
1527            .density(self.density)
1528            .label(self.label.clone())
1529            .trailing_icon(arrow)
1530            .error(self.error)
1531            .disabled(self.disabled)
1532            .w(rem(self.width));
1533        if let Some(leading) = self.leading {
1534            field = field.leading_icon(leading);
1535        }
1536        if let Some(text) = self.supporting.clone() {
1537            field = field.supporting_text(text);
1538        }
1539        if open || keyboard_focus {
1540            field = field.preview_state(InteractionPreview::Focused);
1541        }
1542        let change = self.on_open_change.clone();
1543        let toggle = {
1544            let change = change.clone();
1545            move |next: bool, window: &mut Window, cx: &mut App| {
1546                if let Some(change) = change.as_ref() {
1547                    change(&next, window, cx);
1548                }
1549            }
1550        };
1551        let open_down = toggle.clone();
1552        let open_up = toggle.clone();
1553        let confirm = toggle.clone();
1554        let trigger = div()
1555            .id((id.clone(), "trigger"))
1556            .debug_selector(|| "dropdown-trigger".into())
1557            .role(Role::ComboBox)
1558            .aria_label(self.label.clone())
1559            .aria_value(value)
1560            .aria_expanded(open)
1561            .key_context(DROPDOWN_CONTEXT)
1562            .when(!self.disabled, |this| this.track_focus(&focus))
1563            .when(!self.disabled, |this| this.cursor_pointer())
1564            .on_action(move |_: &SelectDown, window, cx| {
1565                if !open {
1566                    open_down(true, window, cx)
1567                }
1568            })
1569            .on_action(move |_: &SelectUp, window, cx| {
1570                if !open {
1571                    open_up(true, window, cx)
1572                }
1573            })
1574            .on_action(move |_: &Confirm, window, cx| confirm(!open, window, cx))
1575            .child(field);
1576        let selected = self.selected;
1577        let on_select = self.on_select.clone();
1578        let disabled_options = self.disabled_options;
1579        let menu = MaterialMenu::new((id.clone(), "menu"))
1580            .style(self.style)
1581            .width(self.width)
1582            .items(self.options.into_iter().enumerate().map(|(ix, option)| {
1583                let on_select = on_select.clone();
1584                MaterialMenuItem::new(option)
1585                    .selected(selected == Some(ix))
1586                    .disabled(disabled_options.contains(&ix))
1587                    .on_click(move |_, window, cx| {
1588                        if let Some(select) = on_select.as_ref() {
1589                            select(&ix, window, cx);
1590                        }
1591                    })
1592            }));
1593        if self.disabled {
1594            return trigger.into_any_element();
1595        }
1596        MenuPopup::new((id, "popup"), trigger, menu)
1597            .open(open)
1598            .on_open_change(move |next, window, cx| toggle(*next, window, cx))
1599            .into_any_element()
1600    }
1601}
1602
1603#[cfg(test)]
1604#[path = "menu_tests.rs"]
1605mod tests;