1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
72pub enum MenuStyle {
73 Baseline,
75 #[default]
77 Standard,
78 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
98pub enum MenuDensity {
99 #[default]
100 Default,
101 Compact,
103 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 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
134pub 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 pub fn leading(mut self, icon: impl IntoElement) -> Self {
171 self.leading = Some(icon.into_any_element());
172 self
173 }
174 pub fn trailing(mut self, icon: impl IntoElement) -> Self {
176 self.trailing = Some(icon.into_any_element());
177 self
178 }
179 pub fn trailing_text(mut self, text: impl Into<SharedString>) -> Self {
181 self.trailing_text = Some(text.into());
182 self
183 }
184 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 pub fn divider(mut self, divider: bool) -> Self {
199 self.divider = divider;
200 self
201 }
202 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
219pub 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 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#[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
269pub(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
322pub(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
343pub(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
367pub(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
398struct MenuFocus {
400 root: FocusHandle,
401 items: Vec<FocusHandle>,
402}
403
404#[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 pub fn density(mut self, density: MenuDensity) -> Self {
443 self.density = density;
444 self
445 }
446 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 pub fn width(mut self, width: f32) -> Self {
466 self.width = Some(width);
467 self
468 }
469 pub fn elevated(mut self, elevated: bool) -> Self {
472 self.elevated = elevated;
473 self
474 }
475 pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
477 self.focus = Some(focus.clone());
478 self
479 }
480 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 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 pub(crate) fn focus_item(mut self, index: usize) -> Self {
493 self.focus_item = Some(index);
494 self
495 }
496 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 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 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 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 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 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 ("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
798fn 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 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 let container = paint
841 .container
842 .filter(|_| !(disabled && style == MenuStyle::Standard));
843 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 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
957fn 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
978pub(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#[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 pub fn close_on_activate(mut self, close: bool) -> Self {
1052 self.close_on_activate = close;
1053 self
1054 }
1055 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 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 pub fn anchor(mut self, anchor: Anchor) -> Self {
1086 self.anchor = anchor;
1087 self
1088 }
1089}
1090
1091pub(crate) fn opens_upward(anchor: Anchor) -> bool {
1094 matches!(
1095 anchor,
1096 Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight
1097 )
1098}
1099
1100pub(crate) fn cascades_left(anchor: Anchor) -> bool {
1103 matches!(
1104 anchor,
1105 Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter
1106 )
1107}
1108
1109const CASCADE_GAP: f32 = 4.;
1111
1112fn 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 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 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 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 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 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 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 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 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#[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 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 pub fn disabled_options(mut self, options: impl IntoIterator<Item = usize>) -> Self {
1446 self.disabled_options = options.into_iter().collect();
1447 self
1448 }
1449 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 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 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;