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