Skip to repository content2524 lines · 94.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:36:24.338Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
context_menu.rs
1use crate::{
2 ButtonCommon, ButtonStyle, IconButtonShape, KeyBinding, List, ListItem, ListSeparator,
3 ListSubHeader, Tooltip, prelude::*, utils::WithRemSize,
4};
5use gpui::{
6 Action, Anchor, AnyElement, App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle,
7 Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Role,
8 Size, Subscription, TaskExt, anchored, canvas, prelude::*, px,
9};
10use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious};
11use std::{
12 cell::{Cell, RefCell},
13 collections::HashMap,
14 rc::Rc,
15 time::{Duration, Instant},
16};
17
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19enum SubmenuOpenTrigger {
20 Pointer,
21 Keyboard,
22}
23
24struct OpenSubmenu {
25 item_index: usize,
26 entity: Entity<ContextMenu>,
27 trigger_bounds: Option<Bounds<Pixels>>,
28 offset: Option<Pixels>,
29 flip_left: bool,
30 _dismiss_subscription: Subscription,
31}
32
33enum SubmenuState {
34 Closed,
35 Open(OpenSubmenu),
36}
37
38#[derive(Clone, Copy, PartialEq, Eq, Default)]
39enum HoverTarget {
40 #[default]
41 None,
42 MainMenu,
43 Submenu,
44}
45
46pub enum ContextMenuItem {
47 Separator,
48 Header(SharedString),
49 /// title, link_label, link_url
50 HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
51 Label(SharedString),
52 Entry(ContextMenuEntry),
53 CustomEntry {
54 entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
55 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
56 selectable: bool,
57 documentation_aside: Option<DocumentationAside>,
58 },
59 Submenu {
60 label: SharedString,
61 icon: Option<IconName>,
62 icon_color: Option<Color>,
63 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
64 },
65}
66
67impl ContextMenuItem {
68 pub fn custom_entry(
69 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
70 handler: impl Fn(&mut Window, &mut App) + 'static,
71 documentation_aside: Option<DocumentationAside>,
72 ) -> Self {
73 Self::CustomEntry {
74 entry_render: Box::new(entry_render),
75 handler: Rc::new(move |_, window, cx| handler(window, cx)),
76 selectable: true,
77 documentation_aside,
78 }
79 }
80}
81
82pub struct ContextMenuEntry {
83 toggle: Option<(IconPosition, bool)>,
84 label: SharedString,
85 icon: Option<IconName>,
86 custom_icon_path: Option<SharedString>,
87 custom_icon_svg: Option<SharedString>,
88 icon_position: IconPosition,
89 icon_size: IconSize,
90 icon_color: Option<Color>,
91 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
92 secondary_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
93 action: Option<Box<dyn Action>>,
94 disabled: bool,
95 documentation_aside: Option<DocumentationAside>,
96 end_slot_icon: Option<IconName>,
97 end_slot_title: Option<SharedString>,
98 end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
99 show_end_slot_on_hover: bool,
100}
101
102impl ContextMenuEntry {
103 pub fn new(label: impl Into<SharedString>) -> Self {
104 ContextMenuEntry {
105 toggle: None,
106 label: label.into(),
107 icon: None,
108 custom_icon_path: None,
109 custom_icon_svg: None,
110 icon_position: IconPosition::Start,
111 icon_size: IconSize::Small,
112 icon_color: None,
113 handler: Rc::new(|_, _, _| {}),
114 secondary_handler: None,
115 action: None,
116 disabled: false,
117 documentation_aside: None,
118 end_slot_icon: None,
119 end_slot_title: None,
120 end_slot_handler: None,
121 show_end_slot_on_hover: false,
122 }
123 }
124
125 pub fn toggleable(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
126 self.toggle = Some((toggle_position, toggled));
127 self
128 }
129
130 pub fn icon(mut self, icon: IconName) -> Self {
131 self.icon = Some(icon);
132 self
133 }
134
135 pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
136 self.custom_icon_path = Some(path.into());
137 self.custom_icon_svg = None; // Clear other icon sources if custom path is set
138 self.icon = None;
139 self
140 }
141
142 pub fn custom_icon_svg(mut self, svg: impl Into<SharedString>) -> Self {
143 self.custom_icon_svg = Some(svg.into());
144 self.custom_icon_path = None; // Clear other icon sources if custom path is set
145 self.icon = None;
146 self
147 }
148
149 pub fn icon_position(mut self, position: IconPosition) -> Self {
150 self.icon_position = position;
151 self
152 }
153
154 pub fn icon_size(mut self, icon_size: IconSize) -> Self {
155 self.icon_size = icon_size;
156 self
157 }
158
159 pub fn icon_color(mut self, icon_color: Color) -> Self {
160 self.icon_color = Some(icon_color);
161 self
162 }
163
164 pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
165 self.toggle = Some((toggle_position, toggled));
166 self
167 }
168
169 pub fn action(mut self, action: Box<dyn Action>) -> Self {
170 self.action = Some(action);
171 self
172 }
173
174 pub fn handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
175 self.handler = Rc::new(move |_, window, cx| handler(window, cx));
176 self
177 }
178
179 pub fn secondary_handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
180 self.secondary_handler = Some(Rc::new(move |_, window, cx| handler(window, cx)));
181 self
182 }
183
184 pub fn disabled(mut self, disabled: bool) -> Self {
185 self.disabled = disabled;
186 self
187 }
188
189 pub fn documentation_aside(
190 mut self,
191 side: DocumentationSide,
192 render: impl Fn(&mut App) -> AnyElement + 'static,
193 ) -> Self {
194 self.documentation_aside = Some(DocumentationAside {
195 side,
196 render: Rc::new(render),
197 });
198
199 self
200 }
201}
202
203impl FluentBuilder for ContextMenuEntry {}
204
205impl From<ContextMenuEntry> for ContextMenuItem {
206 fn from(entry: ContextMenuEntry) -> Self {
207 ContextMenuItem::Entry(entry)
208 }
209}
210
211pub struct ContextMenu {
212 builder: Option<Rc<dyn Fn(Self, &mut Window, &mut Context<Self>) -> Self>>,
213 items: Vec<ContextMenuItem>,
214 focus_handle: FocusHandle,
215 action_context: Option<FocusHandle>,
216 selected_index: Option<usize>,
217 delayed: bool,
218 clicked: bool,
219 end_slot_action: Option<Box<dyn Action>>,
220 key_context: SharedString,
221 _on_blur_subscription: Subscription,
222 keep_open_on_confirm: bool,
223 fixed_width: Option<DefiniteLength>,
224 main_menu: Option<Entity<ContextMenu>>,
225 main_menu_observed_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
226 // Docs aide-related fields
227 documentation_aside: Option<(usize, DocumentationAside)>,
228 aside_trigger_bounds: Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>,
229 // Submenu-related fields
230 submenu_state: SubmenuState,
231 hover_target: HoverTarget,
232 submenu_safety_threshold_x: Option<Pixels>,
233 submenu_trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
234 submenu_trigger_mouse_down: bool,
235 ignore_blur_until: Option<Instant>,
236 /// When set to true, the next on_focus_in callback will not automatically
237 /// select an item. This prevents a visual flash where a submenu close in
238 /// on_hover(false) returns focus to the main menu and on_focus_in
239 /// re-selects the first item before the next on_hover(true) clears it.
240 suppress_focus_selection: bool,
241}
242
243#[derive(Copy, Clone, PartialEq, Eq)]
244pub enum DocumentationSide {
245 Left,
246 Right,
247}
248
249#[derive(Clone)]
250pub struct DocumentationAside {
251 pub side: DocumentationSide,
252 pub render: Rc<dyn Fn(&mut App) -> AnyElement>,
253}
254
255impl DocumentationAside {
256 pub fn new(side: DocumentationSide, render: Rc<dyn Fn(&mut App) -> AnyElement>) -> Self {
257 Self { side, render }
258 }
259}
260
261impl Focusable for ContextMenu {
262 fn focus_handle(&self, _cx: &App) -> FocusHandle {
263 self.focus_handle.clone()
264 }
265}
266
267impl EventEmitter<DismissEvent> for ContextMenu {}
268
269impl FluentBuilder for ContextMenu {}
270
271impl ContextMenu {
272 pub fn new(
273 window: &mut Window,
274 cx: &mut Context<Self>,
275 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
276 ) -> Self {
277 let focus_handle = cx.focus_handle();
278 let _on_blur_subscription = cx.on_blur(
279 &focus_handle,
280 window,
281 |this: &mut ContextMenu, window, cx| {
282 if let Some(ignore_until) = this.ignore_blur_until {
283 if Instant::now() < ignore_until {
284 return;
285 } else {
286 this.ignore_blur_until = None;
287 }
288 }
289
290 if this.main_menu.is_none() {
291 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
292 let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
293 if submenu_focus.contains_focused(window, cx) {
294 return;
295 }
296 }
297 }
298
299 this.cancel(&menu::Cancel, window, cx)
300 },
301 );
302 window.refresh();
303
304 // When the menu first receives focus (i.e. when it opens), move the
305 // selection onto a menu item so assistive technology announces a real
306 // item rather than the bare menu container. Per the ARIA menu button
307 // pattern, opening a menu places focus on a menu item; for select-style
308 // menus we prefer the currently-checked item. We only do this when
309 // nothing is selected yet so we don't override an existing selection.
310 cx.on_focus_in(&focus_handle, window, |this, window, cx| {
311 if this.selected_index.is_none() && !this.suppress_focus_selection {
312 this.select_toggled_or_first(window, cx);
313 }
314 this.suppress_focus_selection = false;
315 })
316 .detach();
317
318 f(
319 Self {
320 builder: None,
321 items: Default::default(),
322 focus_handle,
323 action_context: None,
324 selected_index: None,
325 delayed: false,
326 clicked: false,
327 end_slot_action: None,
328 key_context: "menu".into(),
329 _on_blur_subscription,
330 keep_open_on_confirm: false,
331 fixed_width: None,
332 main_menu: None,
333 main_menu_observed_bounds: Rc::new(Cell::new(None)),
334 documentation_aside: None,
335 aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
336 submenu_state: SubmenuState::Closed,
337 hover_target: HoverTarget::MainMenu,
338 submenu_safety_threshold_x: None,
339 submenu_trigger_bounds: Rc::new(Cell::new(None)),
340 submenu_trigger_mouse_down: false,
341 ignore_blur_until: None,
342 suppress_focus_selection: false,
343 },
344 window,
345 cx,
346 )
347 }
348
349 pub fn build(
350 window: &mut Window,
351 cx: &mut App,
352 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
353 ) -> Entity<Self> {
354 cx.new(|cx| Self::new(window, cx, f))
355 }
356
357 /// Builds a [`ContextMenu`] that will stay open when making changes instead of closing after each confirmation.
358 ///
359 /// The main difference from [`ContextMenu::build`] is the type of the `builder`, as we need to be able to hold onto
360 /// it to call it again.
361 pub fn build_persistent(
362 window: &mut Window,
363 cx: &mut App,
364 builder: impl Fn(Self, &mut Window, &mut Context<Self>) -> Self + 'static,
365 ) -> Entity<Self> {
366 cx.new(|cx| {
367 let builder = Rc::new(builder);
368
369 let focus_handle = cx.focus_handle();
370 let _on_blur_subscription = cx.on_blur(
371 &focus_handle,
372 window,
373 |this: &mut ContextMenu, window, cx| {
374 if let Some(ignore_until) = this.ignore_blur_until {
375 if Instant::now() < ignore_until {
376 return;
377 } else {
378 this.ignore_blur_until = None;
379 }
380 }
381
382 if this.main_menu.is_none() {
383 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
384 let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
385 if submenu_focus.contains_focused(window, cx) {
386 return;
387 }
388 }
389 }
390
391 this.cancel(&menu::Cancel, window, cx)
392 },
393 );
394 window.refresh();
395
396 // See the note in `ContextMenu::new`: select an item when the menu
397 // opens so screen readers announce it instead of just "menu".
398 cx.on_focus_in(&focus_handle, window, |this, window, cx| {
399 if this.selected_index.is_none() {
400 this.select_toggled_or_first(window, cx);
401 }
402 })
403 .detach();
404
405 (builder.clone())(
406 Self {
407 builder: Some(builder),
408 items: Default::default(),
409 focus_handle,
410 action_context: None,
411 selected_index: None,
412 delayed: false,
413 clicked: false,
414 end_slot_action: None,
415 key_context: "menu".into(),
416 _on_blur_subscription,
417 keep_open_on_confirm: true,
418 fixed_width: None,
419 main_menu: None,
420 main_menu_observed_bounds: Rc::new(Cell::new(None)),
421 documentation_aside: None,
422 aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
423 submenu_state: SubmenuState::Closed,
424 hover_target: HoverTarget::MainMenu,
425 submenu_safety_threshold_x: None,
426 submenu_trigger_bounds: Rc::new(Cell::new(None)),
427 submenu_trigger_mouse_down: false,
428 ignore_blur_until: None,
429 suppress_focus_selection: false,
430 },
431 window,
432 cx,
433 )
434 })
435 }
436
437 /// Rebuilds the menu.
438 ///
439 /// This is used to refresh the menu entries when entries are toggled when the menu is configured with
440 /// `keep_open_on_confirm = true`.
441 ///
442 /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is
443 /// a no-op.
444 pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
445 let Some(builder) = self.builder.clone() else {
446 return;
447 };
448
449 // The way we rebuild the menu is a bit of a hack.
450 let focus_handle = cx.focus_handle();
451 let new_menu = (builder.clone())(
452 Self {
453 builder: Some(builder),
454 items: Default::default(),
455 focus_handle: focus_handle.clone(),
456 action_context: None,
457 selected_index: None,
458 delayed: false,
459 clicked: false,
460 end_slot_action: None,
461 key_context: "menu".into(),
462 _on_blur_subscription: cx.on_blur(
463 &focus_handle,
464 window,
465 |this: &mut ContextMenu, window, cx| {
466 if let Some(ignore_until) = this.ignore_blur_until {
467 if Instant::now() < ignore_until {
468 return;
469 } else {
470 this.ignore_blur_until = None;
471 }
472 }
473
474 if this.main_menu.is_none() {
475 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
476 let submenu_focus =
477 open_submenu.entity.read(cx).focus_handle.clone();
478 if submenu_focus.contains_focused(window, cx) {
479 return;
480 }
481 }
482 }
483
484 this.cancel(&menu::Cancel, window, cx)
485 },
486 ),
487 keep_open_on_confirm: false,
488 fixed_width: None,
489 main_menu: None,
490 main_menu_observed_bounds: Rc::new(Cell::new(None)),
491 documentation_aside: None,
492 aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
493 submenu_state: SubmenuState::Closed,
494 hover_target: HoverTarget::MainMenu,
495 submenu_safety_threshold_x: None,
496 submenu_trigger_bounds: Rc::new(Cell::new(None)),
497 submenu_trigger_mouse_down: false,
498 ignore_blur_until: None,
499 suppress_focus_selection: false,
500 },
501 window,
502 cx,
503 );
504
505 self.items = new_menu.items;
506
507 cx.notify();
508 }
509
510 pub fn context(mut self, focus: FocusHandle) -> Self {
511 self.action_context = Some(focus);
512 self
513 }
514
515 pub fn header(mut self, title: impl Into<SharedString>) -> Self {
516 self.items.push(ContextMenuItem::Header(title.into()));
517 self
518 }
519
520 pub fn header_with_link(
521 mut self,
522 title: impl Into<SharedString>,
523 link_label: impl Into<SharedString>,
524 link_url: impl Into<SharedString>,
525 ) -> Self {
526 self.items.push(ContextMenuItem::HeaderWithLink(
527 title.into(),
528 link_label.into(),
529 link_url.into(),
530 ));
531 self
532 }
533
534 pub fn separator(mut self) -> Self {
535 self.items.push(ContextMenuItem::Separator);
536 self
537 }
538
539 pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
540 self.items.extend(items.into_iter().map(Into::into));
541 self
542 }
543
544 pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
545 self.items.push(item.into());
546 self
547 }
548
549 pub fn push_item(&mut self, item: impl Into<ContextMenuItem>) {
550 self.items.push(item.into());
551 }
552
553 pub fn entry(
554 mut self,
555 label: impl Into<SharedString>,
556 action: Option<Box<dyn Action>>,
557 handler: impl Fn(&mut Window, &mut App) + 'static,
558 ) -> Self {
559 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
560 toggle: None,
561 label: label.into(),
562 handler: Rc::new(move |_, window, cx| handler(window, cx)),
563 secondary_handler: None,
564 icon: None,
565 custom_icon_path: None,
566 custom_icon_svg: None,
567 icon_position: IconPosition::End,
568 icon_size: IconSize::Small,
569 icon_color: None,
570 action,
571 disabled: false,
572 documentation_aside: None,
573 end_slot_icon: None,
574 end_slot_title: None,
575 end_slot_handler: None,
576 show_end_slot_on_hover: false,
577 }));
578 self
579 }
580
581 pub fn entry_with_end_slot(
582 mut self,
583 label: impl Into<SharedString>,
584 action: Option<Box<dyn Action>>,
585 handler: impl Fn(&mut Window, &mut App) + 'static,
586 end_slot_icon: IconName,
587 end_slot_title: SharedString,
588 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
589 ) -> Self {
590 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
591 toggle: None,
592 label: label.into(),
593 handler: Rc::new(move |_, window, cx| handler(window, cx)),
594 secondary_handler: None,
595 icon: None,
596 custom_icon_path: None,
597 custom_icon_svg: None,
598 icon_position: IconPosition::End,
599 icon_size: IconSize::Small,
600 icon_color: None,
601 action,
602 disabled: false,
603 documentation_aside: None,
604 end_slot_icon: Some(end_slot_icon),
605 end_slot_title: Some(end_slot_title),
606 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
607 show_end_slot_on_hover: false,
608 }));
609 self
610 }
611
612 pub fn entry_with_end_slot_on_hover(
613 mut self,
614 label: impl Into<SharedString>,
615 action: Option<Box<dyn Action>>,
616 handler: impl Fn(&mut Window, &mut App) + 'static,
617 end_slot_icon: IconName,
618 end_slot_title: SharedString,
619 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
620 ) -> Self {
621 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
622 toggle: None,
623 label: label.into(),
624 handler: Rc::new(move |_, window, cx| handler(window, cx)),
625 secondary_handler: None,
626 icon: None,
627 custom_icon_path: None,
628 custom_icon_svg: None,
629 icon_position: IconPosition::End,
630 icon_size: IconSize::Small,
631 icon_color: None,
632 action,
633 disabled: false,
634 documentation_aside: None,
635 end_slot_icon: Some(end_slot_icon),
636 end_slot_title: Some(end_slot_title),
637 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
638 show_end_slot_on_hover: true,
639 }));
640 self
641 }
642
643 pub fn toggleable_entry(
644 self,
645 label: impl Into<SharedString>,
646 toggled: bool,
647 position: IconPosition,
648 action: Option<Box<dyn Action>>,
649 handler: impl Fn(&mut Window, &mut App) + 'static,
650 ) -> Self {
651 self.toggleable_entry_disabled_when(label, toggled, false, position, action, handler)
652 }
653
654 /// Like [`Self::toggleable_entry`], but the entry is rendered disabled (and its handler is not
655 /// invoked) when `disabled` is `true`.
656 pub fn toggleable_entry_disabled_when(
657 mut self,
658 label: impl Into<SharedString>,
659 toggled: bool,
660 disabled: bool,
661 position: IconPosition,
662 action: Option<Box<dyn Action>>,
663 handler: impl Fn(&mut Window, &mut App) + 'static,
664 ) -> Self {
665 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
666 toggle: Some((position, toggled)),
667 label: label.into(),
668 handler: Rc::new(move |_, window, cx| handler(window, cx)),
669 secondary_handler: None,
670 icon: None,
671 custom_icon_path: None,
672 custom_icon_svg: None,
673 icon_position: position,
674 icon_size: IconSize::Small,
675 icon_color: None,
676 action,
677 disabled,
678 documentation_aside: None,
679 end_slot_icon: None,
680 end_slot_title: None,
681 end_slot_handler: None,
682 show_end_slot_on_hover: false,
683 }));
684 self
685 }
686
687 pub fn custom_row(
688 mut self,
689 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
690 ) -> Self {
691 self.items.push(ContextMenuItem::CustomEntry {
692 entry_render: Box::new(entry_render),
693 handler: Rc::new(|_, _, _| {}),
694 selectable: false,
695 documentation_aside: None,
696 });
697 self
698 }
699
700 pub fn custom_entry(
701 mut self,
702 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
703 handler: impl Fn(&mut Window, &mut App) + 'static,
704 ) -> Self {
705 self.items.push(ContextMenuItem::CustomEntry {
706 entry_render: Box::new(entry_render),
707 handler: Rc::new(move |_, window, cx| handler(window, cx)),
708 selectable: true,
709 documentation_aside: None,
710 });
711 self
712 }
713
714 pub fn custom_entry_with_docs(
715 mut self,
716 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
717 handler: impl Fn(&mut Window, &mut App) + 'static,
718 documentation_aside: Option<DocumentationAside>,
719 ) -> Self {
720 self.items.push(ContextMenuItem::CustomEntry {
721 entry_render: Box::new(entry_render),
722 handler: Rc::new(move |_, window, cx| handler(window, cx)),
723 selectable: true,
724 documentation_aside,
725 });
726 self
727 }
728
729 pub fn selectable(mut self, selectable: bool) -> Self {
730 if let Some(ContextMenuItem::CustomEntry {
731 selectable: entry_selectable,
732 ..
733 }) = self.items.last_mut()
734 {
735 *entry_selectable = selectable;
736 }
737 self
738 }
739
740 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
741 self.items.push(ContextMenuItem::Label(label.into()));
742 self
743 }
744
745 pub fn action(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
746 self.action_checked(label, action, false)
747 }
748
749 pub fn action_checked(
750 self,
751 label: impl Into<SharedString>,
752 action: Box<dyn Action>,
753 checked: bool,
754 ) -> Self {
755 self.action_checked_with_disabled(label, action, checked, false)
756 }
757
758 pub fn action_checked_with_disabled(
759 mut self,
760 label: impl Into<SharedString>,
761 action: Box<dyn Action>,
762 checked: bool,
763 disabled: bool,
764 ) -> Self {
765 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
766 toggle: if checked {
767 Some((IconPosition::Start, true))
768 } else {
769 None
770 },
771 label: label.into(),
772 action: Some(action.boxed_clone()),
773 handler: Rc::new(move |context, window, cx| {
774 if let Some(context) = &context {
775 window.focus(context, cx);
776 }
777 window.dispatch_action(action.boxed_clone(), cx);
778 }),
779 secondary_handler: None,
780 icon: None,
781 custom_icon_path: None,
782 custom_icon_svg: None,
783 icon_position: IconPosition::End,
784 icon_size: IconSize::Small,
785 icon_color: None,
786 disabled,
787 documentation_aside: None,
788 end_slot_icon: None,
789 end_slot_title: None,
790 end_slot_handler: None,
791 show_end_slot_on_hover: false,
792 }));
793 self
794 }
795
796 pub fn action_disabled_when(
797 mut self,
798 disabled: bool,
799 label: impl Into<SharedString>,
800 action: Box<dyn Action>,
801 ) -> Self {
802 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
803 toggle: None,
804 label: label.into(),
805 action: Some(action.boxed_clone()),
806 handler: Rc::new(move |context, window, cx| {
807 if let Some(context) = &context {
808 window.focus(context, cx);
809 }
810 window.dispatch_action(action.boxed_clone(), cx);
811 }),
812 secondary_handler: None,
813 icon: None,
814 custom_icon_path: None,
815 custom_icon_svg: None,
816 icon_size: IconSize::Small,
817 icon_position: IconPosition::End,
818 icon_color: None,
819 disabled,
820 documentation_aside: None,
821 end_slot_icon: None,
822 end_slot_title: None,
823 end_slot_handler: None,
824 show_end_slot_on_hover: false,
825 }));
826 self
827 }
828
829 pub fn link(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
830 self.link_with_handler(label, action, |_, _| {})
831 }
832
833 pub fn link_with_handler(
834 mut self,
835 label: impl Into<SharedString>,
836 action: Box<dyn Action>,
837 handler: impl Fn(&mut Window, &mut App) + 'static,
838 ) -> Self {
839 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
840 toggle: None,
841 label: label.into(),
842 action: Some(action.boxed_clone()),
843 handler: Rc::new(move |_, window, cx| {
844 handler(window, cx);
845 window.dispatch_action(action.boxed_clone(), cx);
846 }),
847 secondary_handler: None,
848 icon: Some(IconName::ArrowUpRight),
849 custom_icon_path: None,
850 custom_icon_svg: None,
851 icon_size: IconSize::XSmall,
852 icon_position: IconPosition::End,
853 icon_color: None,
854 disabled: false,
855 documentation_aside: None,
856 end_slot_icon: None,
857 end_slot_title: None,
858 end_slot_handler: None,
859 show_end_slot_on_hover: false,
860 }));
861 self
862 }
863
864 pub fn submenu(
865 mut self,
866 label: impl Into<SharedString>,
867 builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
868 ) -> Self {
869 self.items.push(ContextMenuItem::Submenu {
870 label: label.into(),
871 icon: None,
872 icon_color: None,
873 builder: Rc::new(builder),
874 });
875 self
876 }
877
878 pub fn submenu_with_icon(
879 mut self,
880 label: impl Into<SharedString>,
881 icon: IconName,
882 builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
883 ) -> Self {
884 self.items.push(ContextMenuItem::Submenu {
885 label: label.into(),
886 icon: Some(icon),
887 icon_color: None,
888 builder: Rc::new(builder),
889 });
890 self
891 }
892
893 pub fn submenu_with_colored_icon(
894 mut self,
895 label: impl Into<SharedString>,
896 icon: IconName,
897 icon_color: Color,
898 builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
899 ) -> Self {
900 self.items.push(ContextMenuItem::Submenu {
901 label: label.into(),
902 icon: Some(icon),
903 icon_color: Some(icon_color),
904 builder: Rc::new(builder),
905 });
906 self
907 }
908
909 pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
910 self.keep_open_on_confirm = keep_open;
911 self
912 }
913
914 pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
915 let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
916 return;
917 };
918 let ContextMenuItem::Entry(entry) = entry else {
919 return;
920 };
921 let Some(handler) = entry.end_slot_handler.as_ref() else {
922 return;
923 };
924 handler(None, window, cx);
925 }
926
927 pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
928 self.fixed_width = Some(width);
929 self
930 }
931
932 pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
933 self.end_slot_action = Some(action);
934 self
935 }
936
937 pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
938 self.key_context = context.into();
939 self
940 }
941
942 pub fn selected_index(&self) -> Option<usize> {
943 self.selected_index
944 }
945
946 pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
947 let Some(ix) = self.selected_index else {
948 return;
949 };
950
951 if let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) {
952 self.open_submenu(
953 ix,
954 builder.clone(),
955 SubmenuOpenTrigger::Keyboard,
956 window,
957 cx,
958 );
959
960 if let SubmenuState::Open(open_submenu) = &self.submenu_state {
961 let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
962 window.focus(&focus_handle, cx);
963 open_submenu.entity.update(cx, |submenu, cx| {
964 submenu.select_first(&SelectFirst, window, cx);
965 });
966 }
967
968 cx.notify();
969 return;
970 }
971
972 let context = self.action_context.as_ref();
973
974 if let Some(
975 ContextMenuItem::Entry(ContextMenuEntry {
976 handler,
977 disabled: false,
978 ..
979 })
980 | ContextMenuItem::CustomEntry { handler, .. },
981 ) = self.items.get(ix)
982 {
983 (handler)(context, window, cx)
984 }
985
986 if self.main_menu.is_some() && !self.keep_open_on_confirm {
987 self.clicked = true;
988 }
989
990 if self.keep_open_on_confirm {
991 self.rebuild(window, cx);
992 } else {
993 cx.emit(DismissEvent);
994 }
995 }
996
997 pub fn secondary_confirm(
998 &mut self,
999 _: &menu::SecondaryConfirm,
1000 window: &mut Window,
1001 cx: &mut Context<Self>,
1002 ) {
1003 let Some(ix) = self.selected_index else {
1004 return;
1005 };
1006
1007 if let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) {
1008 self.open_submenu(
1009 ix,
1010 builder.clone(),
1011 SubmenuOpenTrigger::Keyboard,
1012 window,
1013 cx,
1014 );
1015
1016 if let SubmenuState::Open(open_submenu) = &self.submenu_state {
1017 let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
1018 window.focus(&focus_handle, cx);
1019 open_submenu.entity.update(cx, |submenu, cx| {
1020 submenu.select_first(&SelectFirst, window, cx);
1021 });
1022 }
1023
1024 cx.notify();
1025 return;
1026 }
1027
1028 let context = self.action_context.as_ref();
1029
1030 if let Some(ContextMenuItem::Entry(ContextMenuEntry {
1031 handler,
1032 secondary_handler,
1033 disabled: false,
1034 ..
1035 })) = self.items.get(ix)
1036 {
1037 if let Some(secondary) = secondary_handler {
1038 (secondary)(context, window, cx)
1039 } else {
1040 (handler)(context, window, cx)
1041 }
1042 } else if let Some(ContextMenuItem::CustomEntry { handler, .. }) = self.items.get(ix) {
1043 (handler)(context, window, cx)
1044 }
1045
1046 if self.main_menu.is_some() && !self.keep_open_on_confirm {
1047 self.clicked = true;
1048 }
1049
1050 if self.keep_open_on_confirm {
1051 self.rebuild(window, cx);
1052 } else {
1053 cx.emit(DismissEvent);
1054 }
1055 }
1056
1057 pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1058 if self.main_menu.is_some() {
1059 cx.emit(DismissEvent);
1060
1061 // Restore keyboard focus to the parent menu so arrow keys / Escape / Enter work again.
1062 if let Some(parent) = &self.main_menu {
1063 let parent_focus = parent.read(cx).focus_handle.clone();
1064
1065 parent.update(cx, |parent, _cx| {
1066 parent.ignore_blur_until = Some(Instant::now() + Duration::from_millis(200));
1067 });
1068
1069 window.focus(&parent_focus, cx);
1070 }
1071
1072 return;
1073 }
1074
1075 cx.emit(DismissEvent);
1076 }
1077
1078 pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1079 let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
1080 return;
1081 };
1082 let ContextMenuItem::Entry(entry) = item else {
1083 return;
1084 };
1085 let Some(handler) = entry.end_slot_handler.as_ref() else {
1086 return;
1087 };
1088 handler(None, window, cx);
1089 self.rebuild(window, cx);
1090 cx.notify();
1091 }
1092
1093 pub fn clear_selected(&mut self) {
1094 self.selected_index = None;
1095 }
1096
1097 pub fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
1098 if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
1099 self.select_index(ix, window, cx);
1100 }
1101 cx.notify();
1102 }
1103
1104 /// Selects the currently-checked entry if one exists (e.g. the active value
1105 /// in a single-select dropdown), otherwise the first selectable item.
1106 ///
1107 /// This is intended to be called when the menu opens. Per the ARIA menu
1108 /// button pattern, opening a menu should place focus on a menu item rather
1109 /// than the menu container, so that assistive technology immediately
1110 /// announces a meaningful item (ideally the current selection) instead of
1111 /// just "menu".
1112 pub fn select_toggled_or_first(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1113 let toggled_ix = self.items.iter().position(|item| {
1114 matches!(
1115 item,
1116 ContextMenuItem::Entry(ContextMenuEntry {
1117 toggle: Some((_, true)),
1118 ..
1119 })
1120 )
1121 });
1122 if let Some(ix) = toggled_ix {
1123 self.select_index(ix, window, cx);
1124 cx.notify();
1125 } else {
1126 self.select_first(&SelectFirst, window, cx);
1127 }
1128 }
1129
1130 pub fn select_last(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<usize> {
1131 for (ix, item) in self.items.iter().enumerate().rev() {
1132 if item.is_selectable() {
1133 return self.select_index(ix, window, cx);
1134 }
1135 }
1136 None
1137 }
1138
1139 fn handle_select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
1140 if self.select_last(window, cx).is_some() {
1141 cx.notify();
1142 }
1143 }
1144
1145 pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1146 if let Some(ix) = self.selected_index {
1147 let next_index = ix + 1;
1148 if self.items.len() <= next_index {
1149 self.select_first(&SelectFirst, window, cx);
1150 return;
1151 } else {
1152 for (ix, item) in self.items.iter().enumerate().skip(next_index) {
1153 if item.is_selectable() {
1154 self.select_index(ix, window, cx);
1155 cx.notify();
1156 return;
1157 }
1158 }
1159 }
1160 }
1161 self.select_first(&SelectFirst, window, cx);
1162 }
1163
1164 pub fn select_previous(
1165 &mut self,
1166 _: &SelectPrevious,
1167 window: &mut Window,
1168 cx: &mut Context<Self>,
1169 ) {
1170 if let Some(ix) = self.selected_index {
1171 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
1172 if item.is_selectable() {
1173 self.select_index(ix, window, cx);
1174 cx.notify();
1175 return;
1176 }
1177 }
1178 }
1179 self.handle_select_last(&SelectLast, window, cx);
1180 }
1181
1182 pub fn select_submenu_child(
1183 &mut self,
1184 _: &SelectChild,
1185 window: &mut Window,
1186 cx: &mut Context<Self>,
1187 ) {
1188 let Some(ix) = self.selected_index else {
1189 return;
1190 };
1191
1192 let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) else {
1193 return;
1194 };
1195
1196 self.open_submenu(
1197 ix,
1198 builder.clone(),
1199 SubmenuOpenTrigger::Keyboard,
1200 window,
1201 cx,
1202 );
1203
1204 if let SubmenuState::Open(open_submenu) = &self.submenu_state {
1205 let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
1206 window.focus(&focus_handle, cx);
1207 open_submenu.entity.update(cx, |submenu, cx| {
1208 submenu.select_first(&SelectFirst, window, cx);
1209 });
1210 }
1211
1212 cx.notify();
1213 }
1214
1215 pub fn select_submenu_parent(
1216 &mut self,
1217 _: &SelectParent,
1218 window: &mut Window,
1219 cx: &mut Context<Self>,
1220 ) {
1221 if self.main_menu.is_none() {
1222 return;
1223 }
1224
1225 if let Some(parent) = &self.main_menu {
1226 let parent_clone = parent.clone();
1227
1228 let parent_focus = parent.read(cx).focus_handle.clone();
1229 window.focus(&parent_focus, cx);
1230
1231 cx.emit(DismissEvent);
1232
1233 parent_clone.update(cx, |parent, cx| {
1234 if let SubmenuState::Open(open_submenu) = &parent.submenu_state {
1235 let trigger_index = open_submenu.item_index;
1236 parent.close_submenu(false, cx);
1237 let _ = parent.select_index(trigger_index, window, cx);
1238 cx.notify();
1239 }
1240 });
1241
1242 return;
1243 }
1244
1245 cx.emit(DismissEvent);
1246 }
1247
1248 fn select_index(
1249 &mut self,
1250 ix: usize,
1251 _window: &mut Window,
1252 _cx: &mut Context<Self>,
1253 ) -> Option<usize> {
1254 self.documentation_aside = None;
1255 let item = self.items.get(ix)?;
1256 if item.is_selectable() {
1257 self.selected_index = Some(ix);
1258 match item {
1259 ContextMenuItem::Entry(entry) => {
1260 if let Some(callback) = &entry.documentation_aside {
1261 self.documentation_aside = Some((ix, callback.clone()));
1262 }
1263 }
1264 ContextMenuItem::CustomEntry {
1265 documentation_aside: Some(callback),
1266 ..
1267 } => {
1268 self.documentation_aside = Some((ix, callback.clone()));
1269 }
1270 ContextMenuItem::Submenu { .. } => {}
1271 _ => (),
1272 }
1273 }
1274 Some(ix)
1275 }
1276
1277 fn create_submenu(
1278 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1279 parent_entity: Entity<ContextMenu>,
1280 window: &mut Window,
1281 cx: &mut Context<Self>,
1282 ) -> (Entity<ContextMenu>, Subscription) {
1283 let submenu = Self::build_submenu(builder, parent_entity, window, cx);
1284
1285 let dismiss_subscription = cx.subscribe(&submenu, |this, submenu, _: &DismissEvent, cx| {
1286 let should_dismiss_parent = submenu.read(cx).clicked;
1287
1288 this.close_submenu(false, cx);
1289
1290 if should_dismiss_parent {
1291 cx.emit(DismissEvent);
1292 }
1293 });
1294
1295 (submenu, dismiss_subscription)
1296 }
1297
1298 fn build_submenu(
1299 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1300 parent_entity: Entity<ContextMenu>,
1301 window: &mut Window,
1302 cx: &mut App,
1303 ) -> Entity<ContextMenu> {
1304 cx.new(|cx| {
1305 let focus_handle = cx.focus_handle();
1306
1307 let _on_blur_subscription = cx.on_blur(
1308 &focus_handle,
1309 window,
1310 |_this: &mut ContextMenu, _window, _cx| {},
1311 );
1312
1313 let mut menu = ContextMenu {
1314 builder: None,
1315 items: Default::default(),
1316 focus_handle,
1317 action_context: None,
1318 selected_index: None,
1319 delayed: false,
1320 clicked: false,
1321 end_slot_action: None,
1322 key_context: "menu".into(),
1323 _on_blur_subscription,
1324 keep_open_on_confirm: false,
1325 fixed_width: None,
1326 documentation_aside: None,
1327 aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
1328 main_menu: Some(parent_entity),
1329 main_menu_observed_bounds: Rc::new(Cell::new(None)),
1330 submenu_state: SubmenuState::Closed,
1331 hover_target: HoverTarget::MainMenu,
1332 submenu_safety_threshold_x: None,
1333 submenu_trigger_bounds: Rc::new(Cell::new(None)),
1334 submenu_trigger_mouse_down: false,
1335 ignore_blur_until: None,
1336 suppress_focus_selection: false,
1337 };
1338
1339 menu = (builder)(menu, window, cx);
1340 menu
1341 })
1342 }
1343
1344 fn close_submenu(&mut self, clear_selection: bool, cx: &mut Context<Self>) {
1345 self.submenu_state = SubmenuState::Closed;
1346 self.hover_target = HoverTarget::MainMenu;
1347 self.submenu_safety_threshold_x = None;
1348 self.main_menu_observed_bounds.set(None);
1349 self.submenu_trigger_bounds.set(None);
1350
1351 if clear_selection {
1352 self.selected_index = None;
1353 }
1354
1355 cx.notify();
1356 }
1357
1358 fn open_submenu(
1359 &mut self,
1360 item_index: usize,
1361 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1362 reason: SubmenuOpenTrigger,
1363 window: &mut Window,
1364 cx: &mut Context<Self>,
1365 ) {
1366 // If the submenu is already open for this item, don't recreate it.
1367 if matches!(
1368 &self.submenu_state,
1369 SubmenuState::Open(open_submenu) if open_submenu.item_index == item_index
1370 ) {
1371 return;
1372 }
1373
1374 let (submenu, dismiss_subscription) =
1375 Self::create_submenu(builder, cx.entity(), window, cx);
1376
1377 let flip_left = self
1378 .main_menu_observed_bounds
1379 .get()
1380 .is_some_and(|bounds| bounds.right() + px(200.0) > window.viewport_size().width);
1381
1382 // If we're switching from one submenu item to another, throw away any previously-captured
1383 // offset so we don't reuse a stale position.
1384 self.main_menu_observed_bounds.set(None);
1385 self.submenu_trigger_bounds.set(None);
1386
1387 self.submenu_safety_threshold_x = None;
1388 self.hover_target = HoverTarget::MainMenu;
1389
1390 // When opening a submenu via keyboard, there is a brief moment where focus/hover can
1391 // transition in a way that triggers the parent menu's `on_blur` dismissal.
1392 if matches!(reason, SubmenuOpenTrigger::Keyboard) {
1393 self.ignore_blur_until = Some(Instant::now() + Duration::from_millis(150));
1394 }
1395
1396 let trigger_bounds = self.submenu_trigger_bounds.get();
1397
1398 self.submenu_state = SubmenuState::Open(OpenSubmenu {
1399 item_index,
1400 entity: submenu,
1401 trigger_bounds,
1402 offset: None,
1403 flip_left,
1404 _dismiss_subscription: dismiss_subscription,
1405 });
1406
1407 cx.notify();
1408 }
1409
1410 pub fn on_action_dispatch(
1411 &mut self,
1412 dispatched: &dyn Action,
1413 window: &mut Window,
1414 cx: &mut Context<Self>,
1415 ) {
1416 if self.clicked {
1417 cx.propagate();
1418 return;
1419 }
1420
1421 if let Some(ix) = self.items.iter().position(|item| {
1422 if let ContextMenuItem::Entry(ContextMenuEntry {
1423 action: Some(action),
1424 disabled: false,
1425 ..
1426 }) = item
1427 {
1428 action.partial_eq(dispatched)
1429 } else {
1430 false
1431 }
1432 }) {
1433 self.select_index(ix, window, cx);
1434 self.delayed = true;
1435 cx.notify();
1436 let action = dispatched.boxed_clone();
1437 cx.spawn_in(window, async move |this, cx| {
1438 cx.background_executor()
1439 .timer(Duration::from_millis(50))
1440 .await;
1441 cx.update(|window, cx| {
1442 this.update(cx, |this, cx| {
1443 this.cancel(&menu::Cancel, window, cx);
1444 window.dispatch_action(action, cx);
1445 })
1446 })
1447 })
1448 .detach_and_log_err(cx);
1449 } else {
1450 cx.propagate()
1451 }
1452 }
1453
1454 pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
1455 self._on_blur_subscription = new_subscription;
1456 self
1457 }
1458
1459 fn render_menu_item(
1460 &self,
1461 ix: usize,
1462 item: &ContextMenuItem,
1463 window: &mut Window,
1464 cx: &mut Context<Self>,
1465 ) -> impl IntoElement + use<> {
1466 // The menu keeps real focus on its container, so for assistive
1467 // technology to track the selected item we report it as the active
1468 // descendant. GPUI only honors this while the menu actually holds
1469 // focus, so we mark the selected item unconditionally here.
1470 let is_active_descendant = |selectable: bool| selectable && Some(ix) == self.selected_index;
1471 match item {
1472 ContextMenuItem::Separator => ListSeparator.into_any_element(),
1473 ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
1474 .inset(true)
1475 .into_any_element(),
1476 ContextMenuItem::HeaderWithLink(header, label, url) => {
1477 let url = url.clone();
1478 let link_id = ElementId::Name(format!("link-{}", url).into());
1479 ListSubHeader::new(header.clone())
1480 .inset(true)
1481 .end_slot(
1482 Button::new(link_id, label.clone())
1483 .color(Color::Muted)
1484 .label_size(LabelSize::Small)
1485 .size(ButtonSize::None)
1486 .style(ButtonStyle::Transparent)
1487 .on_click(move |_, _, cx| {
1488 let url = url.clone();
1489 cx.open_url(&url);
1490 })
1491 .into_any_element(),
1492 )
1493 .into_any_element()
1494 }
1495 ContextMenuItem::Label(label) => ListItem::new(ix)
1496 .inset(true)
1497 .disabled(true)
1498 .child(Label::new(label.clone()))
1499 .into_any_element(),
1500 ContextMenuItem::Entry(entry) => self
1501 .render_menu_entry(ix, entry, is_active_descendant(true), window, cx)
1502 .into_any_element(),
1503 ContextMenuItem::CustomEntry {
1504 entry_render,
1505 handler,
1506 selectable,
1507 documentation_aside,
1508 ..
1509 } => {
1510 let handler = handler.clone();
1511 let menu = cx.entity().downgrade();
1512 let selectable = *selectable;
1513 let aside_trigger_bounds = self.aside_trigger_bounds.clone();
1514
1515 div()
1516 .id(("context-menu-child", ix))
1517 .when_some(documentation_aside.clone(), |this, documentation_aside| {
1518 this.occlude()
1519 .on_hover(cx.listener(move |menu, hovered, _, cx| {
1520 if *hovered {
1521 menu.documentation_aside = Some((ix, documentation_aside.clone()));
1522 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
1523 {
1524 menu.documentation_aside = None;
1525 }
1526 cx.notify();
1527 }))
1528 })
1529 .when(documentation_aside.is_some(), |this| {
1530 this.child(
1531 canvas(
1532 {
1533 let aside_trigger_bounds = aside_trigger_bounds.clone();
1534 move |bounds, _window, _cx| {
1535 aside_trigger_bounds.borrow_mut().insert(ix, bounds);
1536 }
1537 },
1538 |_bounds, _state, _window, _cx| {},
1539 )
1540 .size_full()
1541 .absolute()
1542 .top_0()
1543 .left_0(),
1544 )
1545 })
1546 .child(
1547 ListItem::new(ix)
1548 .inset(true)
1549 .when(selectable, |item| item.aria_role(Role::MenuItem))
1550 .when(is_active_descendant(selectable), |item| {
1551 item.aria_active_descendant()
1552 })
1553 .toggle_state(Some(ix) == self.selected_index)
1554 .selectable(selectable)
1555 .when(selectable, |item| {
1556 item.on_click({
1557 let context = self.action_context.clone();
1558 let keep_open_on_confirm = self.keep_open_on_confirm;
1559 move |_, window, cx| {
1560 handler(context.as_ref(), window, cx);
1561 menu.update(cx, |menu, cx| {
1562 menu.clicked = true;
1563
1564 if keep_open_on_confirm {
1565 menu.rebuild(window, cx);
1566 } else {
1567 cx.emit(DismissEvent);
1568 }
1569 })
1570 .ok();
1571 }
1572 })
1573 })
1574 .child(entry_render(window, cx)),
1575 )
1576 .into_any_element()
1577 }
1578 ContextMenuItem::Submenu {
1579 label,
1580 icon,
1581 icon_color,
1582 ..
1583 } => self
1584 .render_submenu_item_trigger(
1585 ix,
1586 label.clone(),
1587 *icon,
1588 *icon_color,
1589 is_active_descendant(true),
1590 cx,
1591 )
1592 .into_any_element(),
1593 }
1594 }
1595
1596 fn render_submenu_item_trigger(
1597 &self,
1598 ix: usize,
1599 label: SharedString,
1600 icon: Option<IconName>,
1601 icon_color: Option<Color>,
1602 is_active_descendant: bool,
1603 cx: &mut Context<Self>,
1604 ) -> impl IntoElement {
1605 let toggle_state = Some(ix) == self.selected_index
1606 || matches!(
1607 &self.submenu_state,
1608 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1609 );
1610
1611 div()
1612 .id(("context-menu-submenu-trigger", ix))
1613 .capture_any_mouse_down(cx.listener(move |this, event: &MouseDownEvent, _, _| {
1614 // This prevents on_hover(false) from closing the submenu during a click.
1615 if event.button == MouseButton::Left {
1616 this.submenu_trigger_mouse_down = true;
1617 }
1618 }))
1619 .capture_any_mouse_up(cx.listener(move |this, event: &MouseUpEvent, _, _| {
1620 if event.button == MouseButton::Left {
1621 this.submenu_trigger_mouse_down = false;
1622 }
1623 }))
1624 .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| {
1625 if matches!(&this.submenu_state, SubmenuState::Open(_))
1626 || this.selected_index == Some(ix)
1627 {
1628 this.submenu_safety_threshold_x = Some(event.position.x - px(100.0));
1629 }
1630
1631 cx.notify();
1632 }))
1633 .child(
1634 ListItem::new(ix)
1635 .inset(true)
1636 .aria_role(Role::MenuItem)
1637 .when(is_active_descendant, |item| item.aria_active_descendant())
1638 .aria_label(label.clone())
1639 .toggle_state(toggle_state)
1640 .child(
1641 canvas(
1642 {
1643 let trigger_bounds_cell = self.submenu_trigger_bounds.clone();
1644 move |bounds, _window, _cx| {
1645 if toggle_state {
1646 trigger_bounds_cell.set(Some(bounds));
1647 }
1648 }
1649 },
1650 |_bounds, _state, _window, _cx| {},
1651 )
1652 .size_full()
1653 .absolute()
1654 .top_0()
1655 .left_0(),
1656 )
1657 .on_hover(cx.listener(move |this, hovered, window, cx| {
1658 let mouse_pos = window.mouse_position();
1659
1660 if *hovered {
1661 this.clear_selected();
1662 this.suppress_focus_selection = true;
1663 window.focus(&this.focus_handle.clone(), cx);
1664 this.hover_target = HoverTarget::MainMenu;
1665 this.submenu_safety_threshold_x = Some(mouse_pos.x - px(50.0));
1666
1667 if let Some(ContextMenuItem::Submenu { builder, .. }) =
1668 this.items.get(ix)
1669 {
1670 this.open_submenu(
1671 ix,
1672 builder.clone(),
1673 SubmenuOpenTrigger::Pointer,
1674 window,
1675 cx,
1676 );
1677 }
1678
1679 cx.notify();
1680 } else {
1681 if this.submenu_trigger_mouse_down {
1682 return;
1683 }
1684
1685 let is_open_for_this_item = matches!(
1686 &this.submenu_state,
1687 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1688 );
1689
1690 let mouse_in_submenu_zone = this
1691 .padded_submenu_bounds()
1692 .is_some_and(|bounds| bounds.contains(&window.mouse_position()));
1693
1694 if is_open_for_this_item
1695 && this.hover_target != HoverTarget::Submenu
1696 && !mouse_in_submenu_zone
1697 {
1698 this.close_submenu(false, cx);
1699 this.clear_selected();
1700 this.suppress_focus_selection = true;
1701 window.focus(&this.focus_handle.clone(), cx);
1702 cx.notify();
1703 }
1704 }
1705 }))
1706 .on_click(cx.listener(move |this, _, window, cx| {
1707 if matches!(
1708 &this.submenu_state,
1709 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1710 ) {
1711 return;
1712 }
1713
1714 if let Some(ContextMenuItem::Submenu { builder, .. }) = this.items.get(ix) {
1715 this.open_submenu(
1716 ix,
1717 builder.clone(),
1718 SubmenuOpenTrigger::Pointer,
1719 window,
1720 cx,
1721 );
1722 }
1723 }))
1724 .child(
1725 h_flex()
1726 .w_full()
1727 .gap_2()
1728 .justify_between()
1729 .child(
1730 h_flex()
1731 .gap_1p5()
1732 .when_some(icon, |this, icon_name| {
1733 this.child(
1734 Icon::new(icon_name)
1735 .size(IconSize::Small)
1736 .color(icon_color.unwrap_or(Color::Muted)),
1737 )
1738 })
1739 .child(Label::new(label).color(Color::Default)),
1740 )
1741 .child(
1742 Icon::new(IconName::ChevronRight)
1743 .size(IconSize::Small)
1744 .color(Color::Muted),
1745 ),
1746 ),
1747 )
1748 }
1749
1750 fn padded_submenu_bounds(&self) -> Option<Bounds<Pixels>> {
1751 let bounds = self.main_menu_observed_bounds.get()?;
1752 Some(Bounds {
1753 origin: Point {
1754 x: bounds.origin.x - px(50.0),
1755 y: bounds.origin.y - px(50.0),
1756 },
1757 size: Size {
1758 width: bounds.size.width + px(100.0),
1759 height: bounds.size.height + px(100.0),
1760 },
1761 })
1762 }
1763
1764 fn render_submenu_container(
1765 &self,
1766 ix: usize,
1767 submenu: Entity<ContextMenu>,
1768 offset: Pixels,
1769 flip_left: bool,
1770 cx: &mut Context<Self>,
1771 ) -> impl IntoElement {
1772 let bounds_cell = self.main_menu_observed_bounds.clone();
1773 let canvas = canvas(
1774 {
1775 move |bounds, _window, _cx| {
1776 bounds_cell.set(Some(bounds));
1777 }
1778 },
1779 |_bounds, _state, _window, _cx| {},
1780 )
1781 .size_full()
1782 .absolute()
1783 .top_0()
1784 .left_0();
1785
1786 div()
1787 .id(("submenu-container", ix))
1788 .absolute()
1789 .top(offset)
1790 .when(flip_left, |this| this.right_full().mr_neg_0p5())
1791 .when(!flip_left, |this| this.left_full().ml_neg_0p5())
1792 .on_hover(cx.listener(|this, hovered, _, _| {
1793 if *hovered {
1794 this.hover_target = HoverTarget::Submenu;
1795 }
1796 }))
1797 .child(
1798 anchored()
1799 .anchor(if flip_left {
1800 Anchor::TopRight
1801 } else {
1802 Anchor::TopLeft
1803 })
1804 .snap_to_window_with_margin(px(8.0))
1805 .child(
1806 div()
1807 .id(("submenu-hover-zone", ix))
1808 .occlude()
1809 .child(canvas)
1810 .child(submenu),
1811 ),
1812 )
1813 }
1814
1815 fn render_menu_entry(
1816 &self,
1817 ix: usize,
1818 entry: &ContextMenuEntry,
1819 is_active_descendant: bool,
1820 window: &mut Window,
1821 cx: &mut Context<Self>,
1822 ) -> impl IntoElement {
1823 let ContextMenuEntry {
1824 toggle,
1825 label,
1826 handler,
1827 icon,
1828 custom_icon_path,
1829 custom_icon_svg,
1830 icon_position,
1831 icon_size,
1832 icon_color,
1833 action,
1834 disabled,
1835 documentation_aside,
1836 end_slot_icon,
1837 end_slot_title,
1838 end_slot_handler,
1839 show_end_slot_on_hover,
1840 secondary_handler: _,
1841 } = entry;
1842 let this = cx.weak_entity();
1843 // Report the item's keyboard shortcut to assistive technology, resolving
1844 // the action's binding the same way the visible accelerator (rendered
1845 // below) is.
1846 let keyboard_shortcut = action.as_ref().and_then(|action| {
1847 let binding = self
1848 .action_context
1849 .as_ref()
1850 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
1851 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
1852 binding.keyboard_shortcut_text(window, cx)
1853 });
1854
1855 let handler = handler.clone();
1856 let menu = cx.entity().downgrade();
1857
1858 let icon_color = if *disabled {
1859 Color::Muted
1860 } else if toggle.is_some() {
1861 icon_color.unwrap_or(Color::Accent)
1862 } else {
1863 icon_color.unwrap_or(Color::Default)
1864 };
1865
1866 let label_color = if *disabled {
1867 Color::Disabled
1868 } else {
1869 Color::Default
1870 };
1871
1872 let label_element = if let Some(custom_path) = custom_icon_path {
1873 h_flex()
1874 .gap_1p5()
1875 .when(
1876 *icon_position == IconPosition::Start && toggle.is_none(),
1877 |flex| {
1878 flex.child(
1879 Icon::from_path(custom_path.clone())
1880 .size(*icon_size)
1881 .color(icon_color),
1882 )
1883 },
1884 )
1885 .child(Label::new(label.clone()).color(label_color).truncate())
1886 .when(*icon_position == IconPosition::End, |flex| {
1887 flex.child(
1888 Icon::from_path(custom_path.clone())
1889 .size(*icon_size)
1890 .color(icon_color),
1891 )
1892 })
1893 .into_any_element()
1894 } else if let Some(custom_icon_svg) = custom_icon_svg {
1895 h_flex()
1896 .gap_1p5()
1897 .when(
1898 *icon_position == IconPosition::Start && toggle.is_none(),
1899 |flex| {
1900 flex.child(
1901 Icon::from_external_svg(custom_icon_svg.clone())
1902 .size(*icon_size)
1903 .color(icon_color),
1904 )
1905 },
1906 )
1907 .child(Label::new(label.clone()).color(label_color).truncate())
1908 .when(*icon_position == IconPosition::End, |flex| {
1909 flex.child(
1910 Icon::from_external_svg(custom_icon_svg.clone())
1911 .size(*icon_size)
1912 .color(icon_color),
1913 )
1914 })
1915 .into_any_element()
1916 } else if let Some(icon_name) = icon {
1917 h_flex()
1918 .gap_1p5()
1919 .when(
1920 *icon_position == IconPosition::Start && toggle.is_none(),
1921 |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
1922 )
1923 .child(Label::new(label.clone()).color(label_color).truncate())
1924 .when(*icon_position == IconPosition::End, |flex| {
1925 flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
1926 })
1927 .into_any_element()
1928 } else {
1929 Label::new(label.clone())
1930 .color(label_color)
1931 .truncate()
1932 .into_any_element()
1933 };
1934
1935 let aside_trigger_bounds = self.aside_trigger_bounds.clone();
1936
1937 div()
1938 .id(("context-menu-child", ix))
1939 .when_some(documentation_aside.clone(), |this, documentation_aside| {
1940 this.occlude()
1941 .on_hover(cx.listener(move |menu, hovered, _, cx| {
1942 if *hovered {
1943 menu.documentation_aside = Some((ix, documentation_aside.clone()));
1944 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
1945 menu.documentation_aside = None;
1946 }
1947 cx.notify();
1948 }))
1949 })
1950 .when(documentation_aside.is_some(), |this| {
1951 this.child(
1952 canvas(
1953 {
1954 let aside_trigger_bounds = aside_trigger_bounds.clone();
1955 move |bounds, _window, _cx| {
1956 aside_trigger_bounds.borrow_mut().insert(ix, bounds);
1957 }
1958 },
1959 |_bounds, _state, _window, _cx| {},
1960 )
1961 .size_full()
1962 .absolute()
1963 .top_0()
1964 .left_0(),
1965 )
1966 })
1967 .child(
1968 ListItem::new(ix)
1969 .group_name("label_container")
1970 .inset(true)
1971 .disabled(*disabled)
1972 .aria_role(if toggle.is_some() {
1973 Role::MenuItemCheckBox
1974 } else {
1975 Role::MenuItem
1976 })
1977 .when_some(*toggle, |item, (_, checked)| item.aria_checked(checked))
1978 .when(is_active_descendant, |item| item.aria_active_descendant())
1979 .aria_label(label.clone())
1980 .when_some(keyboard_shortcut, |item, keyboard_shortcut| {
1981 item.aria_keyshortcuts(keyboard_shortcut)
1982 })
1983 .toggle_state(Some(ix) == self.selected_index)
1984 .when(self.main_menu.is_none() && !*disabled, |item| {
1985 item.on_hover(cx.listener(move |this, hovered, window, cx| {
1986 if *hovered {
1987 this.clear_selected();
1988 this.suppress_focus_selection = true;
1989 window.focus(&this.focus_handle.clone(), cx);
1990
1991 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
1992 if open_submenu.item_index != ix {
1993 this.close_submenu(false, cx);
1994 cx.notify();
1995 }
1996 }
1997 }
1998 }))
1999 })
2000 .when(self.main_menu.is_some(), |item| {
2001 item.on_click(cx.listener(move |this, _, window, cx| {
2002 if matches!(
2003 &this.submenu_state,
2004 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
2005 ) {
2006 return;
2007 }
2008
2009 if let Some(ContextMenuItem::Submenu { builder, .. }) =
2010 this.items.get(ix)
2011 {
2012 this.open_submenu(
2013 ix,
2014 builder.clone(),
2015 SubmenuOpenTrigger::Pointer,
2016 window,
2017 cx,
2018 );
2019 }
2020 }))
2021 .on_hover(cx.listener(
2022 move |this, hovered, window, cx| {
2023 if *hovered {
2024 this.clear_selected();
2025 cx.notify();
2026 }
2027
2028 if let Some(parent) = &this.main_menu {
2029 let mouse_pos = window.mouse_position();
2030 let parent_clone = parent.clone();
2031
2032 if *hovered {
2033 parent.update(cx, |parent, _| {
2034 parent.clear_selected();
2035 parent.hover_target = HoverTarget::Submenu;
2036 });
2037 } else {
2038 parent_clone.update(cx, |parent, cx| {
2039 if matches!(
2040 &parent.submenu_state,
2041 SubmenuState::Open(_)
2042 ) {
2043 // Only close if mouse is to the left of the safety threshold
2044 // (prevents accidental close when moving diagonally toward submenu)
2045 let should_close = parent
2046 .submenu_safety_threshold_x
2047 .map(|threshold_x| mouse_pos.x < threshold_x)
2048 .unwrap_or(true);
2049
2050 if should_close {
2051 parent.close_submenu(true, cx);
2052 }
2053 }
2054 });
2055 }
2056 }
2057 },
2058 ))
2059 })
2060 .when_some(*toggle, |list_item, (position, toggled)| {
2061 let contents = div()
2062 .flex_none()
2063 .child(
2064 Icon::new(icon.unwrap_or(IconName::Check))
2065 .color(icon_color)
2066 .size(*icon_size),
2067 )
2068 .when(!toggled, |contents| contents.invisible());
2069
2070 match position {
2071 IconPosition::Start => list_item.start_slot(contents),
2072 IconPosition::End => list_item.end_slot(contents),
2073 }
2074 })
2075 .child(
2076 h_flex()
2077 .w_full()
2078 .justify_between()
2079 .child(label_element)
2080 .debug_selector(|| format!("MENU_ITEM-{}", label))
2081 .children(action.as_ref().map(|action| {
2082 let binding = self
2083 .action_context
2084 .as_ref()
2085 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
2086 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
2087
2088 div()
2089 .ml_4()
2090 .child(binding.disabled(*disabled))
2091 .when(*disabled && documentation_aside.is_some(), |parent| {
2092 parent.invisible()
2093 })
2094 }))
2095 .when(*disabled && documentation_aside.is_some(), |parent| {
2096 parent.child(
2097 Icon::new(IconName::Info)
2098 .size(IconSize::XSmall)
2099 .color(Color::Muted),
2100 )
2101 }),
2102 )
2103 .when_some(
2104 end_slot_icon
2105 .as_ref()
2106 .zip(self.end_slot_action.as_ref())
2107 .zip(end_slot_title.as_ref())
2108 .zip(end_slot_handler.as_ref()),
2109 |el, (((icon, action), title), handler)| {
2110 el.end_slot({
2111 let icon_button = IconButton::new("end-slot-icon", *icon)
2112 .shape(IconButtonShape::Square)
2113 .style(ButtonStyle::Subtle)
2114 .tooltip({
2115 let action_context = self.action_context.clone();
2116 let title = title.clone();
2117 let action = action.boxed_clone();
2118 move |_window, cx| {
2119 action_context
2120 .as_ref()
2121 .map(|focus| {
2122 Tooltip::for_action_in(
2123 title.clone(),
2124 &*action,
2125 focus,
2126 cx,
2127 )
2128 })
2129 .unwrap_or_else(|| {
2130 Tooltip::for_action(title.clone(), &*action, cx)
2131 })
2132 }
2133 })
2134 .on_click({
2135 let handler = handler.clone();
2136 move |_, window, cx| {
2137 handler(None, window, cx);
2138 this.update(cx, |this, cx| {
2139 this.rebuild(window, cx);
2140 cx.notify();
2141 })
2142 .ok();
2143 }
2144 });
2145
2146 if *show_end_slot_on_hover {
2147 div()
2148 .visible_on_hover("label_container")
2149 .child(icon_button)
2150 .into_any_element()
2151 } else {
2152 icon_button.into_any_element()
2153 }
2154 })
2155 },
2156 )
2157 .on_click({
2158 let context = self.action_context.clone();
2159 let keep_open_on_confirm = self.keep_open_on_confirm;
2160 move |_, window, cx| {
2161 handler(context.as_ref(), window, cx);
2162 menu.update(cx, |menu, cx| {
2163 menu.clicked = true;
2164 if keep_open_on_confirm {
2165 menu.rebuild(window, cx);
2166 } else {
2167 cx.emit(DismissEvent);
2168 }
2169 })
2170 .ok();
2171 }
2172 }),
2173 )
2174 .into_any_element()
2175 }
2176}
2177
2178impl ContextMenuItem {
2179 fn is_selectable(&self) -> bool {
2180 match self {
2181 ContextMenuItem::Header(_)
2182 | ContextMenuItem::HeaderWithLink(_, _, _)
2183 | ContextMenuItem::Separator
2184 | ContextMenuItem::Label { .. } => false,
2185 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
2186 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
2187 ContextMenuItem::Submenu { .. } => true,
2188 }
2189 }
2190}
2191
2192impl Render for ContextMenu {
2193 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2194 let ui_font_size = theme::theme_settings(cx).ui_font_size(cx);
2195 let window_size = window.viewport_size();
2196 let rem_size = window.rem_size();
2197 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
2198
2199 let mut focus_submenu: Option<FocusHandle> = None;
2200
2201 let submenu_container = match &mut self.submenu_state {
2202 SubmenuState::Open(open_submenu) => {
2203 let is_initializing = open_submenu.offset.is_none();
2204
2205 let computed_offset = if is_initializing {
2206 let menu_bounds = self.main_menu_observed_bounds.get();
2207 let trigger_bounds = open_submenu
2208 .trigger_bounds
2209 .or_else(|| self.submenu_trigger_bounds.get());
2210
2211 match (menu_bounds, trigger_bounds) {
2212 (Some(menu_bounds), Some(trigger_bounds)) => {
2213 Some(trigger_bounds.origin.y - menu_bounds.origin.y)
2214 }
2215 _ => None,
2216 }
2217 } else {
2218 None
2219 };
2220
2221 if let Some(offset) = open_submenu.offset.or(computed_offset) {
2222 if open_submenu.offset.is_none() {
2223 open_submenu.offset = Some(offset);
2224 }
2225
2226 focus_submenu = Some(open_submenu.entity.read(cx).focus_handle.clone());
2227 Some((
2228 open_submenu.item_index,
2229 open_submenu.entity.clone(),
2230 offset,
2231 open_submenu.flip_left,
2232 ))
2233 } else {
2234 None
2235 }
2236 }
2237 _ => None,
2238 };
2239
2240 let aside = self.documentation_aside.clone();
2241 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
2242 WithRemSize::new(ui_font_size)
2243 .occlude()
2244 .elevation_2(cx)
2245 .w_full()
2246 .p_2()
2247 .overflow_hidden()
2248 .when(is_wide_window, |this| this.max_w_96())
2249 .when(!is_wide_window, |this| this.max_w_48())
2250 .child((aside.render)(cx))
2251 };
2252
2253 let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
2254 let bounds_cell = self.main_menu_observed_bounds.clone();
2255 let menu_bounds_measure = canvas(
2256 {
2257 move |bounds, _window, _cx| {
2258 bounds_cell.set(Some(bounds));
2259 }
2260 },
2261 |_bounds, _state, _window, _cx| {},
2262 )
2263 .size_full()
2264 .absolute()
2265 .top_0()
2266 .left_0();
2267
2268 WithRemSize::new(ui_font_size)
2269 .occlude()
2270 .elevation_2(cx)
2271 .flex()
2272 .flex_row()
2273 .flex_shrink_0()
2274 .child(
2275 v_flex()
2276 .id("context-menu")
2277 .role(Role::Menu)
2278 .max_h(vh(0.75, window))
2279 .flex_shrink_0()
2280 .child(menu_bounds_measure)
2281 .when_some(self.fixed_width, |this, width| {
2282 this.w(width).overflow_x_hidden()
2283 })
2284 .when(self.fixed_width.is_none(), |this| {
2285 this.min_w(px(200.)).flex_1()
2286 })
2287 .overflow_y_scroll()
2288 .track_focus(&self.focus_handle(cx))
2289 .key_context(self.key_context.as_ref())
2290 .on_action(cx.listener(ContextMenu::select_first))
2291 .on_action(cx.listener(ContextMenu::handle_select_last))
2292 .on_action(cx.listener(ContextMenu::select_next))
2293 .on_action(cx.listener(ContextMenu::select_previous))
2294 .on_action(cx.listener(ContextMenu::select_submenu_child))
2295 .on_action(cx.listener(ContextMenu::select_submenu_parent))
2296 .on_action(cx.listener(ContextMenu::confirm))
2297 .on_action(cx.listener(ContextMenu::secondary_confirm))
2298 .on_action(cx.listener(ContextMenu::cancel))
2299 .on_hover(cx.listener(|this, hovered: &bool, _, cx| {
2300 if *hovered {
2301 this.hover_target = HoverTarget::MainMenu;
2302 if let Some(parent) = &this.main_menu {
2303 parent.update(cx, |parent, _| {
2304 parent.hover_target = HoverTarget::Submenu;
2305 });
2306 }
2307 }
2308 }))
2309 .on_mouse_down_out(cx.listener(
2310 |this, event: &MouseDownEvent, window, cx| {
2311 if matches!(&this.submenu_state, SubmenuState::Open(_)) {
2312 if let Some(padded_bounds) = this.padded_submenu_bounds() {
2313 if padded_bounds.contains(&event.position) {
2314 return;
2315 }
2316 }
2317 }
2318
2319 if let Some(parent) = &this.main_menu {
2320 let overridden_by_parent_trigger = parent
2321 .read(cx)
2322 .submenu_trigger_bounds
2323 .get()
2324 .is_some_and(|bounds| bounds.contains(&event.position));
2325 if overridden_by_parent_trigger {
2326 return;
2327 }
2328 }
2329
2330 this.cancel(&menu::Cancel, window, cx)
2331 },
2332 ))
2333 .when_some(self.end_slot_action.as_ref(), |el, action| {
2334 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
2335 })
2336 .when(!self.delayed, |mut el| {
2337 for item in self.items.iter() {
2338 if let ContextMenuItem::Entry(ContextMenuEntry {
2339 action: Some(action),
2340 disabled: false,
2341 ..
2342 }) = item
2343 {
2344 el = el.on_boxed_action(
2345 &**action,
2346 cx.listener(ContextMenu::on_action_dispatch),
2347 );
2348 }
2349 }
2350 el
2351 })
2352 .child(
2353 List::new().children(
2354 self.items
2355 .iter()
2356 .enumerate()
2357 .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
2358 ),
2359 ),
2360 )
2361 };
2362
2363 if let Some(focus_handle) = focus_submenu.as_ref() {
2364 window.focus(focus_handle, cx);
2365 }
2366
2367 if is_wide_window {
2368 let menu_bounds = self.main_menu_observed_bounds.get();
2369 let trigger_bounds = self
2370 .documentation_aside
2371 .as_ref()
2372 .and_then(|(ix, _)| self.aside_trigger_bounds.borrow().get(ix).copied());
2373
2374 let trigger_position = match (menu_bounds, trigger_bounds) {
2375 (Some(menu_bounds), Some(trigger_bounds)) => {
2376 let relative_top = trigger_bounds.origin.y - menu_bounds.origin.y;
2377 let height = trigger_bounds.size.height;
2378 Some((relative_top, height))
2379 }
2380 _ => None,
2381 };
2382
2383 div()
2384 .relative()
2385 .child(render_menu(cx, window))
2386 // Only render the aside once we have trigger bounds to avoid flicker.
2387 .when_some(trigger_position, |this, (top, height)| {
2388 this.children(aside.map(|(_, aside)| {
2389 h_flex()
2390 .absolute()
2391 .when(aside.side == DocumentationSide::Left, |el| {
2392 el.right_full().mr_1()
2393 })
2394 .when(aside.side == DocumentationSide::Right, |el| {
2395 el.left_full().ml_1()
2396 })
2397 .top(top)
2398 .h(height)
2399 .child(render_aside(aside, cx))
2400 }))
2401 })
2402 .when_some(
2403 submenu_container,
2404 |this, (ix, submenu, offset, flip_left)| {
2405 this.child(
2406 self.render_submenu_container(ix, submenu, offset, flip_left, cx),
2407 )
2408 },
2409 )
2410 } else {
2411 v_flex()
2412 .w_full()
2413 .relative()
2414 .gap_1()
2415 .justify_end()
2416 .children(aside.map(|(_, aside)| render_aside(aside, cx)))
2417 .child(render_menu(cx, window))
2418 .when_some(
2419 submenu_container,
2420 |this, (ix, submenu, offset, flip_left)| {
2421 this.child(
2422 self.render_submenu_container(ix, submenu, offset, flip_left, cx),
2423 )
2424 },
2425 )
2426 }
2427 }
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432 use gpui::TestAppContext;
2433
2434 use super::*;
2435
2436 #[gpui::test]
2437 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
2438 let cx = cx.add_empty_window();
2439 let context_menu = cx.update(|window, cx| {
2440 ContextMenu::build(window, cx, |menu, _, _| {
2441 menu.header("First header")
2442 .separator()
2443 .entry("First entry", None, |_, _| {})
2444 .separator()
2445 .separator()
2446 .entry("Last entry", None, |_, _| {})
2447 .header("Last header")
2448 })
2449 });
2450
2451 context_menu.update_in(cx, |context_menu, window, cx| {
2452 assert_eq!(
2453 None, context_menu.selected_index,
2454 "No selection is in the menu initially"
2455 );
2456
2457 context_menu.select_first(&SelectFirst, window, cx);
2458 assert_eq!(
2459 Some(2),
2460 context_menu.selected_index,
2461 "Should select first selectable entry, skipping the header and the separator"
2462 );
2463
2464 context_menu.select_next(&SelectNext, window, cx);
2465 assert_eq!(
2466 Some(5),
2467 context_menu.selected_index,
2468 "Should select next selectable entry, skipping 2 separators along the way"
2469 );
2470
2471 context_menu.select_next(&SelectNext, window, cx);
2472 assert_eq!(
2473 Some(2),
2474 context_menu.selected_index,
2475 "Should wrap around to first selectable entry"
2476 );
2477 });
2478
2479 context_menu.update_in(cx, |context_menu, window, cx| {
2480 assert_eq!(
2481 Some(2),
2482 context_menu.selected_index,
2483 "Should start from the first selectable entry"
2484 );
2485
2486 context_menu.select_previous(&SelectPrevious, window, cx);
2487 assert_eq!(
2488 Some(5),
2489 context_menu.selected_index,
2490 "Should wrap around to previous selectable entry (last)"
2491 );
2492
2493 context_menu.select_previous(&SelectPrevious, window, cx);
2494 assert_eq!(
2495 Some(2),
2496 context_menu.selected_index,
2497 "Should go back to previous selectable entry (first)"
2498 );
2499 });
2500
2501 context_menu.update_in(cx, |context_menu, window, cx| {
2502 context_menu.select_first(&SelectFirst, window, cx);
2503 assert_eq!(
2504 Some(2),
2505 context_menu.selected_index,
2506 "Should start from the first selectable entry"
2507 );
2508
2509 context_menu.select_previous(&SelectPrevious, window, cx);
2510 assert_eq!(
2511 Some(5),
2512 context_menu.selected_index,
2513 "Should wrap around to last selectable entry"
2514 );
2515 context_menu.select_next(&SelectNext, window, cx);
2516 assert_eq!(
2517 Some(2),
2518 context_menu.selected_index,
2519 "Should wrap around to first selectable entry"
2520 );
2521 });
2522 }
2523}
2524