1use crate::{ActiveTheme, ButtonVariant, IconName, button, icon, keystroke_label};
3use gpui_kit::base::{ElementExt, Popover, Popup};
4use gpui_kit::rems;
5use gpui_kit::{
6 Action, Anchor, AnyElement, App, Bounds, ElementId, Entity, Focusable, KeyDownEvent,
7 ParentElement, Pixels, Point, SharedString, Window, anchored, deferred, div, point, prelude::*,
8 px,
9};
10use std::{rc::Rc, time::Duration};
11
12type Select = Rc<dyn Fn(usize, &mut Window, &mut App)>;
14
15const ROW_HEIGHT: f32 = 1.75;
17const ROW_GAP: f32 = 0.125;
18const SEPARATOR_HEIGHT: f32 = 0.5625;
20const MENU_PADDING: f32 = 0.375;
21const SUBMENU_WIDTH: f32 = 10.625;
22const SUBMENU_GAP: f32 = 0.25;
24
25#[derive(Clone)]
26pub struct MenuItem {
27 pub label: SharedString,
28 pub icon: Option<IconName>,
29 pub shortcut: Option<SharedString>,
30 pub action: Option<Rc<dyn Action>>,
32 pub disabled: bool,
33 pub checked: Option<bool>,
34 pub separator_before: bool,
35 pub children: Vec<(usize, MenuItem)>,
37}
38impl MenuItem {
39 pub fn new(label: impl Into<SharedString>) -> Self {
40 Self {
41 label: label.into(),
42 icon: None,
43 shortcut: None,
44 action: None,
45 disabled: false,
46 checked: None,
47 separator_before: false,
48 children: Vec::new(),
49 }
50 }
51 pub fn submenu(mut self, children: Vec<(usize, MenuItem)>) -> Self {
56 self.children = children;
57 self
58 }
59 pub fn checked(mut self, checked: bool) -> Self {
61 self.checked = Some(checked);
62 self
63 }
64 pub fn separator_before(mut self) -> Self {
65 self.separator_before = true;
66 self
67 }
68 pub fn icon(mut self, icon: IconName) -> Self {
69 self.icon = Some(icon);
70 self
71 }
72 pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
73 self.shortcut = Some(shortcut.into());
74 self
75 }
76 pub fn action(mut self, action: impl Action) -> Self {
80 self.action = Some(Rc::new(action));
81 self
82 }
83 fn render_shortcut(&self, window: &Window, cx: &App) -> Option<AnyElement> {
85 self.action
86 .as_ref()
87 .and_then(|action| window.highest_precedence_binding_for_action(action.as_ref()))
88 .map(|binding| {
89 binding
90 .keystrokes()
91 .iter()
92 .map(|stroke| keystroke_label(stroke.modifiers(), stroke.key()).to_string())
93 .collect::<Vec<_>>()
94 .join(" ")
95 .into()
96 })
97 .or_else(|| self.shortcut.clone())
98 .map(|label: SharedString| {
99 div()
100 .font_family(cx.macos().font.clone())
101 .text_size(rems(0.75))
102 .opacity(0.72)
103 .child(label)
104 .into_any_element()
105 })
106 }
107 pub fn disabled(mut self, disabled: bool) -> Self {
108 self.disabled = disabled;
109 self
110 }
111}
112
113pub fn menu(
116 id: impl Into<ElementId>,
117 trigger: impl gpui_kit::base::Selectable
118 + IntoElement
119 + gpui_kit::StatefulInteractiveElement
120 + 'static,
121 items: Vec<MenuItem>,
122 on_select: impl Fn(usize, &mut Window, &mut App) + 'static,
123) -> Popover {
124 let on_select = Rc::new(on_select);
125 Popover::new(id)
126 .trigger_clickable(trigger)
127 .overlay_closable(false)
130 .content_factory(move |popover, window, cx| {
131 if crate::native::is_glass_popup(window) {
132 let first = window.use_keyed_state("menu-native-focus", cx, |_, _| true);
133 if *first.read(cx) {
134 first.update(cx, |first, _| *first = false);
135 popover.focus_handle(cx).focus(window, cx);
136 }
137 }
138 let t = cx.macos().clone();
139 let cursor = window.use_keyed_state("menu-cursor", cx, |_, _| {
140 items.iter().position(|item| !item.disabled)
141 });
142 let focus = window
143 .use_keyed_state("menu-focus", cx, |_, cx| cx.focus_handle())
144 .read(cx)
145 .clone();
146 if popover.focus_handle(cx).is_focused(window) {
147 focus.focus(window, cx);
148 }
149 let page = window.use_keyed_state("menu-page", cx, |_, _| None::<usize>);
151 let child_cursor = window.use_keyed_state("submenu-cursor", cx, |_, _| None::<usize>);
152 let menu_bounds = window.use_keyed_state("menu-bounds", cx, |_, _| Bounds::default());
155 let submenu_bounds =
156 window.use_keyed_state("submenu-bounds", cx, |_, _| None::<Bounds<Pixels>>);
157
158 let displayed = items.clone();
162 let leaf_select = on_select.clone();
163 let select_page = page.clone();
164 let select_child = child_cursor.clone();
165 let select_close = cx.entity();
166 let command_owner = popover.owner_window();
167 let on_select: Select = Rc::new(move |index, window, cx| {
168 if let Some(parent) = *select_page.read(cx)
169 && let Some(child) = *select_child.read(cx)
170 {
171 if let Some((action, item)) = displayed[parent].children.get(child)
172 && !item.disabled
173 {
174 select_close.update(cx, |state, cx| state.dismiss(window, cx));
175 dispatch_selection(command_owner, leaf_select.clone(), *action, window, cx);
176 }
177 return;
178 }
179 let Some(item) = displayed.get(index) else {
180 return;
181 };
182 if !item.children.is_empty() {
183 select_page.update(cx, |page, cx| {
184 *page = Some(index);
185 cx.notify();
186 });
187 select_child.update(cx, |child, cx| {
188 *child = item.children.iter().position(|(_, item)| !item.disabled);
189 cx.notify();
190 });
191 window.refresh();
192 } else if !item.disabled {
193 select_close.update(cx, |state, cx| state.dismiss(window, cx));
194 dispatch_selection(command_owner, leaf_select.clone(), index, window, cx);
195 }
196 });
197
198 let has_icons = items.iter().any(|item| item.icon.is_some());
199 let open_page = *page.read(cx);
200 let keyboard_items = items.clone();
201 let keyboard_page = page.clone();
202 let keyboard_child = child_cursor.clone();
203 let keyboard_cursor = cursor.clone();
204 let keyboard_select = on_select.clone();
205 let confirm_cursor = cursor.clone();
206 let confirm_select = on_select.clone();
207 let submenu_items = items.clone();
208 let submenu_cursor = child_cursor.clone();
209 let submenu_page = page.clone();
210 let submenu_select = on_select.clone();
211 let main = div()
212 .id("menu-items")
213 .debug_selector(|| "macos-menu-content".into())
214 .role(gpui_kit::Role::Menu)
215 .track_focus(&focus)
216 .w(rems(15.))
217 .p(rems(MENU_PADDING))
218 .flex()
219 .flex_col()
220 .gap(rems(ROW_GAP))
221 .border_1()
222 .rounded(rems(0.75))
223 .border_color(t.glass_border())
224 .bg(if crate::native::is_glass_popup(window) {
225 gpui_kit::transparent_black()
226 } else {
227 let mut fill = t.glass_fill();
228 fill.a = 1.;
229 fill
230 })
231 .shadow(t.popover_shadow())
232 .when(crate::native::is_glass_popup(window), |menu| {
233 menu.border_0().shadow_none()
234 })
235 .text_color(t.foreground)
236 .font_family(t.font.clone())
237 .text_size(rems(0.8125))
238 .on_action(move |_: &gpui_kit::base::actions::Confirm, window, cx| {
239 cx.stop_propagation();
240 if let Some(index) = *confirm_cursor.read(cx) {
241 confirm_select(index, window, cx);
242 window.refresh();
243 }
244 })
245 .on_key_down(move |event: &KeyDownEvent, window, cx| {
246 if event.keystroke.modifiers.modified() {
247 return;
248 }
249 let key = event.keystroke.key.as_str();
250 let current = *keyboard_cursor.read(cx);
251 if key == "left" && keyboard_page.read(cx).is_some() {
252 keyboard_page.update(cx, |page, cx| {
253 *page = None;
254 cx.notify();
255 });
256 keyboard_child.update(cx, |child, cx| {
257 *child = None;
258 cx.notify();
259 });
260 window.refresh();
261 } else if key == "right" {
262 if keyboard_page.read(cx).is_none()
263 && let Some(index) =
264 current.filter(|&i| !keyboard_items[i].children.is_empty())
265 {
266 keyboard_select(index, window, cx);
267 }
268 } else if matches!(key, "enter" | "space") {
269 if let Some(index) = current.filter(|&i| !keyboard_items[i].disabled) {
270 keyboard_select(index, window, cx);
271 window.refresh();
272 }
273 } else if matches!(key, "down" | "j" | "up" | "k" | "home" | "end") {
274 if let Some(parent) = *keyboard_page.read(cx) {
276 let enabled: Vec<_> = keyboard_items[parent]
277 .children
278 .iter()
279 .enumerate()
280 .filter_map(|(i, (_, item))| (!item.disabled).then_some(i))
281 .collect();
282 let next = next_item(&enabled, *keyboard_child.read(cx), key);
283 keyboard_child.update(cx, |child, cx| {
284 *child = next;
285 cx.notify();
286 });
287 window.refresh();
288 cx.stop_propagation();
289 return;
290 }
291 let enabled: Vec<_> = keyboard_items
292 .iter()
293 .enumerate()
294 .filter_map(|(i, item)| (!item.disabled).then_some(i))
295 .collect();
296 let next = next_item(&enabled, current, key);
297 keyboard_cursor.update(cx, |cursor, cx| {
298 *cursor = next;
299 cx.notify();
300 });
301 window.refresh();
302 } else {
303 return;
304 }
305 cx.stop_propagation();
306 })
307 .children(items.clone().into_iter().enumerate().map(|(index, item)| {
308 let hover_cursor = cursor.clone();
309 let hover_page = page.clone();
310 let hover_child = child_cursor.clone();
311 let item_has_children = !item.children.is_empty();
312 let click_page = page.clone();
313 let click_child = child_cursor.clone();
314 let current = *cursor.read(cx) == Some(index);
315 let select = on_select.clone();
316 let shortcut = item.render_shortcut(window, cx);
317 let row = button(("menu-item", index), "", ButtonVariant::Secondary, cx)
318 .debug_selector(move || format!("macos-menu-item-{index}"))
319 .accessibility_label(item.label.clone())
320 .role(gpui_kit::Role::MenuItem)
321 .when_some(item.checked, |row, checked| {
322 row.role(gpui_kit::Role::MenuItemRadio)
323 .aria_toggled(if checked {
324 gpui_kit::accesskit::Toggled::True
325 } else {
326 gpui_kit::accesskit::Toggled::False
327 })
328 })
329 .focusable(false)
330 .disabled(item.disabled)
331 .fill_duration(Duration::ZERO)
332 .w_full()
333 .h(rems(ROW_HEIGHT))
334 .py(rems(0.))
335 .px(rems(0.5))
336 .rounded(rems(0.375))
337 .border_0()
338 .shadow_none()
339 .justify_start()
340 .bg(if current {
341 t.accent
342 } else {
343 t.foreground.opacity(0.)
344 })
345 .text_color(if current { t.on_accent } else { t.foreground })
346 .hover(|s| s.bg(t.accent).text_color(t.on_accent))
347 .active(|s| s.bg(t.accent).text_color(t.on_accent))
348 .when(has_icons, |row| {
349 row.child(
350 div()
351 .w(rems(0.875))
352 .flex_shrink_0()
353 .when_some(item.icon, |slot, name| {
354 slot.child(icon(name).size(rems(0.875)))
355 }),
356 )
357 })
358 .child(div().flex_1().min_w_0().child(item.label))
359 .when(item_has_children, |row| {
360 row.child(icon(IconName::ChevronRight).size(rems(0.875)))
361 })
362 .when_some(item.checked, |row, checked| {
363 row.child(div().w(rems(0.875)).when(checked, |slot| {
364 slot.child(icon(IconName::Check).size(rems(0.875)))
365 }))
366 })
367 .children(shortcut)
368 .on_hover(move |hovered, window, cx| {
369 if *hovered && !item.disabled {
370 hover_page.update(cx, |page, cx| {
371 *page = item_has_children.then_some(index);
372 cx.notify();
373 });
374 hover_child.update(cx, |child, cx| {
375 *child = None;
376 cx.notify();
377 });
378 hover_cursor.update(cx, |cursor, cx| {
379 *cursor = Some(index);
380 cx.notify();
381 });
382 window.refresh();
383 }
384 })
385 .on_click(move |_, window, cx| {
386 click_page.update(cx, |page, _| *page = None);
389 click_child.update(cx, |child, _| *child = None);
390 select(index, window, cx);
391 window.refresh();
392 });
393 div()
394 .when(item.separator_before, |group| {
395 group.child(div().py(rems(0.25)).child(crate::separator(cx)))
396 })
397 .child(row)
398 }));
399 let dismiss_bounds = submenu_bounds.clone();
400 let dismiss = cx.entity();
401 if open_page.is_none() {
402 submenu_bounds.update(cx, |slot, _| *slot = None);
403 }
404 let submenu = open_page.map(|parent| {
405 let rem = window.rem_size();
406 let native = crate::native::is_glass_popup(window);
407 let viewport = if native {
411 window.display(cx).map(|display| {
412 let display = display.bounds();
413 Bounds::new(display.origin - window.bounds().origin, display.size)
414 })
415 } else {
416 None
417 }
418 .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), window.viewport_size()));
419 let (anchor, origin) = submenu_placement(
420 *menu_bounds.read(cx),
421 rems(submenu_offset(&submenu_items, parent)).to_pixels(rem),
422 rems(SUBMENU_WIDTH).to_pixels(rem),
423 rems(SUBMENU_GAP).to_pixels(rem),
424 viewport.right(),
425 );
426 if native {
427 return native_submenu(
428 parent,
429 SubmenuPosition {
430 anchor,
431 origin,
432 viewport,
433 },
434 submenu_items[parent].children.clone(),
435 submenu_cursor,
436 submenu_page,
437 submenu_select,
438 )
439 .into_any_element();
440 }
441 let record_submenu = submenu_bounds.clone();
442 deferred(
443 anchored()
444 .anchor(anchor)
445 .position(origin)
446 .snap_to_window_with_margin(px(4.))
447 .child(
448 div()
452 .on_prepaint(move |bounds, _, cx| {
453 record_submenu.update(cx, |slot, _| *slot = Some(bounds))
454 })
455 .child(submenu_panel(
456 &submenu_items[parent].children,
457 parent,
458 submenu_cursor,
459 submenu_page,
460 submenu_select,
461 window,
462 cx,
463 )),
464 ),
465 )
466 .with_priority(gpui_kit::base::POPUP_PRIORITY + 1)
468 .into_any_element()
469 });
470 div()
471 .on_mouse_down_out(move |_, window, cx| {
472 let inside_submenu = dismiss_bounds
473 .read(cx)
474 .is_some_and(|bounds| bounds.contains(&window.mouse_position()));
475 if !inside_submenu {
476 dismiss.update(cx, |state, cx| state.dismiss(window, cx));
477 window.refresh();
478 }
479 })
480 .on_prepaint(move |bounds, _, cx| menu_bounds.update(cx, |slot, _| *slot = bounds))
482 .child(main)
483 .children(submenu)
484 })
485}
486
487fn dispatch_selection(
490 owner: Option<gpui_kit::AnyWindowHandle>,
491 select: Select,
492 index: usize,
493 window: &mut Window,
494 cx: &mut App,
495) {
496 if let Some(owner) = owner
497 && owner.window_id() != window.window_handle().window_id()
498 {
499 cx.defer(move |cx| {
500 let _ = owner.update(cx, |_, window, cx| {
501 select(index, window, cx);
502 window.refresh();
503 });
504 });
505 } else {
506 select(index, window, cx);
507 }
508}
509
510struct SubmenuPosition {
514 anchor: Anchor,
515 origin: Point<Pixels>,
516 viewport: Bounds<Pixels>,
517}
518
519fn native_submenu(
520 parent: usize,
521 position: SubmenuPosition,
522 items: Vec<(usize, MenuItem)>,
523 cursor: Entity<Option<usize>>,
524 page: Entity<Option<usize>>,
525 select: Select,
526) -> Popup {
527 let dismiss_page = page.clone();
528 let dismiss_cursor = cursor.clone();
529 Popup::new(("submenu-popup", parent), div().size_0())
530 .absolute()
531 .left(position.origin.x)
532 .top(position.origin.y)
533 .size_0()
534 .anchor(position.anchor)
535 .viewport_bounds(position.viewport)
536 .focus(false)
537 .content_factory(move |window, cx| {
538 submenu_panel(
539 &items,
540 parent,
541 cursor.clone(),
542 page.clone(),
543 select.clone(),
544 window,
545 cx,
546 )
547 .into_any_element()
548 })
549 .on_dismiss(move |window, cx| {
550 if *dismiss_page.read(cx) == Some(parent) {
553 dismiss_page.update(cx, |page, cx| {
554 *page = None;
555 cx.notify();
556 });
557 dismiss_cursor.update(cx, |cursor, cx| {
558 *cursor = None;
559 cx.notify();
560 });
561 window.refresh();
562 }
563 })
564}
565
566fn submenu_placement(
571 menu: Bounds<Pixels>,
572 top: Pixels,
573 width: Pixels,
574 gap: Pixels,
575 viewport_width: Pixels,
576) -> (Anchor, Point<Pixels>) {
577 let top = menu.top() + top;
578 if menu.right() + gap + width <= viewport_width {
579 (Anchor::TopLeft, point(menu.right() + gap, top))
580 } else {
581 (Anchor::TopRight, point(menu.left() - gap, top))
582 }
583}
584
585fn submenu_panel(
589 items: &[(usize, MenuItem)],
590 parent: usize,
591 cursor: Entity<Option<usize>>,
592 page: Entity<Option<usize>>,
593 select: Select,
594 window: &Window,
595 cx: &App,
596) -> impl IntoElement {
597 let t = cx.macos().clone();
598 let has_icons = items.iter().any(|(_, item)| item.icon.is_some());
599 let highlighted = *cursor.read(cx);
600 div()
601 .id("submenu")
602 .debug_selector(|| "macos-submenu-content".into())
603 .role(gpui_kit::Role::Menu)
604 .occlude()
605 .w(rems(SUBMENU_WIDTH))
606 .p(rems(MENU_PADDING))
607 .flex()
608 .flex_col()
609 .gap(rems(ROW_GAP))
610 .border_1()
611 .rounded(rems(0.75))
612 .border_color(t.glass_border())
613 .bg(if crate::native::is_glass_popup(window) {
614 gpui_kit::transparent_black()
615 } else {
616 t.glass_fill()
617 })
618 .shadow(t.popover_shadow())
619 .when(crate::native::is_glass_popup(window), |menu| {
620 menu.border_0().shadow_none()
621 })
622 .text_color(t.foreground)
623 .font_family(t.font.clone())
624 .text_size(rems(0.8125))
625 .children(items.iter().enumerate().map(|(index, (_, item))| {
626 let hover_cursor = cursor.clone();
627 let click_cursor = cursor.clone();
628 let click_page = page.clone();
629 let select = select.clone();
630 let disabled = item.disabled;
631 button(("submenu-item", index), "", ButtonVariant::Secondary, cx)
632 .debug_selector(move || format!("macos-submenu-item-{index}"))
633 .accessibility_label(item.label.clone())
634 .role(gpui_kit::Role::MenuItem)
635 .when_some(item.checked, |row, checked| {
636 row.role(gpui_kit::Role::MenuItemRadio)
637 .aria_toggled(if checked {
638 gpui_kit::accesskit::Toggled::True
639 } else {
640 gpui_kit::accesskit::Toggled::False
641 })
642 })
643 .focusable(false)
644 .disabled(disabled)
645 .fill_duration(Duration::ZERO)
646 .w_full()
647 .h(rems(ROW_HEIGHT))
648 .py(rems(0.))
649 .px(rems(0.5))
650 .rounded(rems(0.375))
651 .border_0()
652 .shadow_none()
653 .justify_start()
654 .bg(if highlighted == Some(index) {
655 t.accent
656 } else {
657 t.foreground.opacity(0.)
658 })
659 .text_color(if highlighted == Some(index) {
660 t.on_accent
661 } else {
662 t.foreground
663 })
664 .hover(|s| s.bg(t.accent).text_color(t.on_accent))
665 .active(|s| s.bg(t.accent).text_color(t.on_accent))
666 .when(has_icons, |row| {
667 row.child(
668 div()
669 .w(rems(0.875))
670 .flex_shrink_0()
671 .when_some(item.icon, |slot, name| {
672 slot.child(icon(name).size(rems(0.875)))
673 }),
674 )
675 })
676 .child(div().flex_1().min_w_0().child(item.label.clone()))
677 .when_some(item.checked, |row, checked| {
678 row.child(div().w(rems(0.875)).when(checked, |slot| {
679 slot.child(icon(IconName::Check).size(rems(0.875)))
680 }))
681 })
682 .children(item.render_shortcut(window, cx))
683 .on_hover(move |hovered, window, cx| {
684 if *hovered && !disabled {
685 hover_cursor.update(cx, |cursor, cx| {
686 *cursor = Some(index);
687 cx.notify();
688 });
689 window.refresh();
690 }
691 })
692 .on_click(move |_, window, cx| {
693 click_page.update(cx, |page, _| *page = Some(parent));
694 click_cursor.update(cx, |cursor, _| *cursor = Some(index));
695 select(parent, window, cx);
696 window.refresh();
697 })
698 }))
699}
700
701fn submenu_offset(items: &[MenuItem], parent: usize) -> f32 {
705 let separator = |item: &MenuItem| {
706 if item.separator_before {
707 SEPARATOR_HEIGHT
708 } else {
709 0.
710 }
711 };
712 items
713 .iter()
714 .take(parent)
715 .map(|item| ROW_HEIGHT + ROW_GAP + separator(item))
716 .sum::<f32>()
717 + items.get(parent).map_or(0., separator)
718}
719
720fn next_item(enabled: &[usize], current: Option<usize>, key: &str) -> Option<usize> {
721 if enabled.is_empty() {
722 return None;
723 }
724 let position = current.and_then(|i| enabled.iter().position(|&item| item == i));
725 let index = match key {
726 "home" => 0,
727 "end" => enabled.len() - 1,
728 "up" | "k" => position.map_or(enabled.len() - 1, |i| {
729 (i + enabled.len() - 1) % enabled.len()
730 }),
731 _ => position.map_or(0, |i| (i + 1) % enabled.len()),
732 };
733 Some(enabled[index])
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use gpui_kit::base::{PopupContentFactory, PopupPresenter};
740 use gpui_kit::{AppContext, Context, Render, TestAppContext, VisualTestContext, size};
741 use std::cell::RefCell;
742
743 struct SubmenuContent(PopupContentFactory);
744
745 impl Render for SubmenuContent {
746 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
747 (self.0)(window, cx)
748 }
749 }
750
751 struct NativeSubmenuHarness {
752 page: Entity<Option<usize>>,
753 cursor: Entity<Option<usize>>,
754 commands: Rc<RefCell<Vec<(usize, gpui_kit::AnyWindowHandle)>>>,
755 }
756
757 impl Render for NativeSubmenuHarness {
758 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
759 let owner = window.window_handle();
760 let commands = self.commands.clone();
761 let select: Select = Rc::new(move |index, window, cx| {
762 let commands = commands.clone();
763 dispatch_selection(
764 Some(owner),
765 Rc::new(move |index, window, _| {
766 commands.borrow_mut().push((index, window.window_handle()));
767 }),
768 index,
769 window,
770 cx,
771 );
772 });
773 div().size_full().child(native_submenu(
774 2,
775 SubmenuPosition {
776 anchor: Anchor::TopLeft,
777 origin: point(px(244.), px(40.)),
778 viewport: Bounds::new(point(px(-600.), px(-400.)), size(px(1800.), px(1200.))),
779 },
780 vec![(20, MenuItem::new("PDF")), (21, MenuItem::new("Image"))],
781 self.cursor.clone(),
782 self.page.clone(),
783 select,
784 ))
785 }
786 }
787
788 #[gpui_kit::test]
789 fn native_submenu_uses_a_separate_panel_and_dispatches_to_original_owner(
790 cx: &mut TestAppContext,
791 ) {
792 cx.update(crate::init);
793 let pane = Rc::new(RefCell::new(
794 None::<crate::native::GlassPanelHandle<SubmenuContent>>,
795 ));
796 let resolved = Rc::new(RefCell::new(Vec::new()));
797 let pane_for_hook = pane.clone();
798 let observed = resolved.clone();
799 cx.update(move |cx| {
800 cx.set_global(PopupPresenter(Rc::new(move |request, owner, cx| {
801 observed.borrow_mut().push(request.bounds);
802 assert!(
803 !request.focus,
804 "Submenu keeps keyboard navigation in the main menu"
805 );
806 if pane_for_hook.borrow().is_none() {
807 let dismiss = request.on_dismiss;
808 let panel = crate::native::open_glass_panel(
809 crate::native::GlassPanelOptions::new(request.bounds),
810 owner,
811 cx,
812 |_, cx| cx.new(|_| SubmenuContent(request.content)),
813 move |owner, cx| {
814 if let Some(dismiss) = &dismiss {
815 dismiss(owner, cx);
816 }
817 },
818 )
819 .unwrap();
820 *pane_for_hook.borrow_mut() = Some(panel);
821 }
822 true
823 })));
824 });
825 let commands = Rc::new(RefCell::new(Vec::new()));
826 let (view, owner_cx) = cx.add_window_view({
827 let commands = commands.clone();
828 move |window, cx| {
829 window.resize(size(px(240.), px(160.)));
830 NativeSubmenuHarness {
831 page: cx.new(|_| Some(2)),
832 cursor: cx.new(|_| None),
833 commands,
834 }
835 }
836 });
837 let owner_handle = owner_cx.update(|window, cx| {
838 window.draw(cx).clear(cx);
839 window.window_handle()
840 });
841 let bounds = resolved.borrow()[0];
842 assert_eq!(bounds.origin, point(px(244.), px(40.)));
843 assert!(
844 bounds.left() > px(240.),
845 "Child is outside the small owner's viewport"
846 );
847 assert!(owner_cx.debug_bounds("macos-submenu-content").is_none());
848 let child_handle = pane.borrow().as_ref().unwrap().window_handle();
849 let mut child_cx = VisualTestContext::from_window(child_handle, owner_cx);
850 child_cx.update(|window, cx| window.draw(cx).clear(cx));
851 let target = child_cx
852 .debug_bounds("macos-submenu-item-1")
853 .unwrap()
854 .center();
855 child_cx.simulate_click(target, Default::default());
856 child_cx.run_until_parked();
857 assert_eq!(&*commands.borrow(), &[(2, owner_handle)]);
858 assert_eq!(
859 view.read_with(&child_cx, |view, cx| *view.cursor.read(cx)),
860 Some(1)
861 );
862 child_cx.update(|window, _| window.remove_window());
863 child_cx.run_until_parked();
864 view.read_with(&child_cx, |view, cx| {
865 assert_eq!(*view.page.read(cx), None);
866 assert_eq!(*view.cursor.read(cx), None);
867 });
868 }
869
870 #[test]
871 fn navigation_skips_disabled_and_wraps() {
872 assert_eq!(next_item(&[0, 2], Some(0), "down"), Some(2));
873 assert_eq!(next_item(&[0, 2], Some(2), "down"), Some(0));
874 assert_eq!(next_item(&[0, 2], Some(0), "up"), Some(2));
875 assert_eq!(next_item(&[], None, "home"), None);
876 }
877
878 #[test]
879 fn submenu_opens_right_and_flips_left_at_the_viewport_edge() {
880 let menu = Bounds::new(point(px(100.), px(40.)), gpui_kit::size(px(240.), px(160.)));
881 let open = |viewport| submenu_placement(menu, px(30.), px(170.), px(4.), viewport);
882
883 assert_eq!(open(px(800.)), (Anchor::TopLeft, point(px(344.), px(70.))));
885 assert_eq!(open(px(514.)).0, Anchor::TopLeft);
887 assert_eq!(open(px(513.)), (Anchor::TopRight, point(px(96.), px(70.))));
889 }
890
891 #[test]
892 fn submenu_lines_up_with_the_row_that_opened_it() {
893 let items = vec![
894 MenuItem::new("first"),
895 MenuItem::new("second").separator_before(),
896 MenuItem::new("third"),
897 ];
898 assert_eq!(submenu_offset(&items, 0), 0.);
899 assert_eq!(
900 submenu_offset(&items, 1),
901 ROW_HEIGHT + ROW_GAP + SEPARATOR_HEIGHT
902 );
903 assert_eq!(
904 submenu_offset(&items, 2),
905 2. * (ROW_HEIGHT + ROW_GAP) + SEPARATOR_HEIGHT
906 );
907 }
908
909 #[test]
910 fn submenu_children_carry_their_own_action_index() {
911 let item = MenuItem::new("Theme").submenu(vec![
912 (20, MenuItem::new("System").checked(true)),
913 (21, MenuItem::new("Light")),
914 ]);
915 assert_eq!(item.children.len(), 2);
916 assert_eq!(item.children[0].0, 20);
917 assert_eq!(item.children[1].0, 21);
918 }
919}