From 469ad070b03da71043640a95e77238ad5873d031 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 30 Dec 2025 07:49:01 -0300
Subject: [PATCH] ui: Add submenus to `ContextMenu` (#45743)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR introduces submenu functionality for Zed's dropdown/context
menus.
```rs
.submenu("Trigger", |menu, _, _| {
menu.entry("Item…", None, |_, _| {})
.entry("Item…", None, |_, _| {})
})
```
Release Notes:
- N/A
---------
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
---
assets/keymaps/default-linux.json | 7 +
assets/keymaps/default-macos.json | 7 +
assets/keymaps/default-windows.json | 7 +
crates/menu/src/menu.rs | 4 +
crates/ui/src/components/context_menu.rs | 881 ++++++++++++++++++++--
crates/ui/src/components/dropdown_menu.rs | 32 +
6 files changed, 862 insertions(+), 76 deletions(-)
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index e2a0bd89b5..1abe2be13d 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -55,6 +55,13 @@
"down": "menu::SelectNext",
},
},
+ {
+ "context": "menu",
+ "bindings": {
+ "right": "menu::SelectChild",
+ "left": "menu::SelectParent",
+ },
+ },
{
"context": "Editor",
"bindings": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 2524d98e37..73c3181b63 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -54,6 +54,13 @@
"ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
},
},
+ {
+ "context": "menu",
+ "bindings": {
+ "right": "menu::SelectChild",
+ "left": "menu::SelectParent",
+ },
+ },
{
"context": "Editor",
"use_key_equivalents": true,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 039ae40821..7f1869e577 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -54,6 +54,13 @@
"down": "menu::SelectNext",
},
},
+ {
+ "context": "menu",
+ "bindings": {
+ "right": "menu::SelectChild",
+ "left": "menu::SelectParent",
+ },
+ },
{
"context": "Editor",
"use_key_equivalents": true,
diff --git a/crates/menu/src/menu.rs b/crates/menu/src/menu.rs
index 9a1937d100..afa361ba38 100644
--- a/crates/menu/src/menu.rs
+++ b/crates/menu/src/menu.rs
@@ -26,6 +26,10 @@ actions!(
SelectFirst,
/// Selects the last item in the menu.
SelectLast,
+ /// Enters a submenu (navigates to child menu).
+ SelectChild,
+ /// Exits a submenu (navigates to parent menu).
+ SelectParent,
/// Restarts the menu from the beginning.
Restart,
EndSlot,
diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs
index 7e5e9032c9..bcde8ee745 100644
--- a/crates/ui/src/components/context_menu.rs
+++ b/crates/ui/src/components/context_menu.rs
@@ -1,17 +1,48 @@
use crate::{
- Icon, IconButtonShape, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator,
- ListSubHeader, h_flex, prelude::*, utils::WithRemSize, v_flex,
+ IconButtonShape, KeyBinding, List, ListItem, ListSeparator, ListSubHeader, Tooltip, prelude::*,
+ utils::WithRemSize,
};
use gpui::{
- Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle,
- Focusable, IntoElement, Render, Subscription, px,
+ Action, AnyElement, App, Bounds, Corner, DismissEvent, Entity, EventEmitter, FocusHandle,
+ Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size,
+ Subscription, anchored, canvas, prelude::*, px,
};
-use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious};
use settings::Settings;
-use std::{rc::Rc, time::Duration};
+use std::{
+ cell::Cell,
+ rc::Rc,
+ time::{Duration, Instant},
+};
use theme::ThemeSettings;
-use super::Tooltip;
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum SubmenuOpenTrigger {
+ Pointer,
+ Keyboard,
+}
+
+struct OpenSubmenu {
+ item_index: usize,
+ entity: Entity,
+ trigger_bounds: Option>,
+ // Capture the submenu's vertical offset once and keep it stable while the submenu is open.
+ offset: Option,
+ _dismiss_subscription: Subscription,
+}
+
+enum SubmenuState {
+ Closed,
+ Open(OpenSubmenu),
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Default)]
+enum HoverTarget {
+ #[default]
+ None,
+ MainMenu,
+ Submenu,
+}
pub enum ContextMenuItem {
Separator,
@@ -26,6 +57,11 @@ pub enum ContextMenuItem {
selectable: bool,
documentation_aside: Option,
},
+ Submenu {
+ label: SharedString,
+ icon: Option,
+ builder: Rc) -> ContextMenu>,
+ },
}
impl ContextMenuItem {
@@ -181,6 +217,15 @@ pub struct ContextMenu {
keep_open_on_confirm: bool,
documentation_aside: Option<(usize, DocumentationAside)>,
fixed_width: Option,
+ // Submenu-related fields
+ parent_menu: Option>,
+ submenu_state: SubmenuState,
+ hover_target: HoverTarget,
+ submenu_safety_threshold_x: Option,
+ submenu_observed_bounds: Rc>>>,
+ submenu_trigger_bounds: Rc| >>>,
+ submenu_trigger_mouse_down: bool,
+ ignore_blur_until: Option,
}
#[derive(Copy, Clone, PartialEq, Eq)]
@@ -233,7 +278,26 @@ impl ContextMenu {
let _on_blur_subscription = cx.on_blur(
&focus_handle,
window,
- |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
+ |this: &mut ContextMenu, window, cx| {
+ if let Some(ignore_until) = this.ignore_blur_until {
+ if Instant::now() < ignore_until {
+ return;
+ } else {
+ this.ignore_blur_until = None;
+ }
+ }
+
+ if this.parent_menu.is_none() {
+ if let SubmenuState::Open(open_submenu) = &this.submenu_state {
+ let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
+ if submenu_focus.contains_focused(window, cx) {
+ return;
+ }
+ }
+ }
+
+ this.cancel(&menu::Cancel, window, cx)
+ },
);
window.refresh();
@@ -246,12 +310,20 @@ impl ContextMenu {
selected_index: None,
delayed: false,
clicked: false,
+ end_slot_action: None,
key_context: "menu".into(),
_on_blur_subscription,
keep_open_on_confirm: false,
documentation_aside: None,
fixed_width: None,
- end_slot_action: None,
+ parent_menu: None,
+ submenu_state: SubmenuState::Closed,
+ hover_target: HoverTarget::MainMenu,
+ submenu_safety_threshold_x: None,
+ submenu_observed_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_mouse_down: false,
+ ignore_blur_until: None,
},
window,
cx,
@@ -282,7 +354,26 @@ impl ContextMenu {
let _on_blur_subscription = cx.on_blur(
&focus_handle,
window,
- |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
+ |this: &mut ContextMenu, window, cx| {
+ if let Some(ignore_until) = this.ignore_blur_until {
+ if Instant::now() < ignore_until {
+ return;
+ } else {
+ this.ignore_blur_until = None;
+ }
+ }
+
+ if this.parent_menu.is_none() {
+ if let SubmenuState::Open(open_submenu) = &this.submenu_state {
+ let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
+ if submenu_focus.contains_focused(window, cx) {
+ return;
+ }
+ }
+ }
+
+ this.cancel(&menu::Cancel, window, cx)
+ },
);
window.refresh();
@@ -295,12 +386,20 @@ impl ContextMenu {
selected_index: None,
delayed: false,
clicked: false,
+ end_slot_action: None,
key_context: "menu".into(),
_on_blur_subscription,
keep_open_on_confirm: true,
documentation_aside: None,
fixed_width: None,
- end_slot_action: None,
+ parent_menu: None,
+ submenu_state: SubmenuState::Closed,
+ hover_target: HoverTarget::MainMenu,
+ submenu_safety_threshold_x: None,
+ submenu_observed_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_mouse_down: false,
+ ignore_blur_until: None,
},
window,
cx,
@@ -331,16 +430,44 @@ impl ContextMenu {
selected_index: None,
delayed: false,
clicked: false,
+ end_slot_action: None,
key_context: "menu".into(),
_on_blur_subscription: cx.on_blur(
&focus_handle,
window,
- |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
+ |this: &mut ContextMenu, window, cx| {
+ if let Some(ignore_until) = this.ignore_blur_until {
+ if Instant::now() < ignore_until {
+ return;
+ } else {
+ this.ignore_blur_until = None;
+ }
+ }
+
+ if this.parent_menu.is_none() {
+ if let SubmenuState::Open(open_submenu) = &this.submenu_state {
+ let submenu_focus =
+ open_submenu.entity.read(cx).focus_handle.clone();
+ if submenu_focus.contains_focused(window, cx) {
+ return;
+ }
+ }
+ }
+
+ this.cancel(&menu::Cancel, window, cx)
+ },
),
keep_open_on_confirm: false,
documentation_aside: None,
fixed_width: None,
- end_slot_action: None,
+ parent_menu: None,
+ submenu_state: SubmenuState::Closed,
+ hover_target: HoverTarget::MainMenu,
+ submenu_safety_threshold_x: None,
+ submenu_observed_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_mouse_down: false,
+ ignore_blur_until: None,
},
window,
cx,
@@ -636,6 +763,33 @@ impl ContextMenu {
self
}
+ pub fn submenu(
+ mut self,
+ label: impl Into,
+ builder: impl Fn(ContextMenu, &mut Window, &mut Context) -> ContextMenu + 'static,
+ ) -> Self {
+ self.items.push(ContextMenuItem::Submenu {
+ label: label.into(),
+ icon: None,
+ builder: Rc::new(builder),
+ });
+ self
+ }
+
+ pub fn submenu_with_icon(
+ mut self,
+ label: impl Into,
+ icon: IconName,
+ builder: impl Fn(ContextMenu, &mut Window, &mut Context) -> ContextMenu + 'static,
+ ) -> Self {
+ self.items.push(ContextMenuItem::Submenu {
+ label: label.into(),
+ icon: Some(icon),
+ builder: Rc::new(builder),
+ });
+ self
+ }
+
pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
self.keep_open_on_confirm = keep_open;
self
@@ -683,6 +837,10 @@ impl ContextMenu {
(handler)(context, window, cx)
}
+ if self.parent_menu.is_some() && !self.keep_open_on_confirm {
+ self.clicked = true;
+ }
+
if self.keep_open_on_confirm {
self.rebuild(window, cx);
} else {
@@ -690,8 +848,24 @@ impl ContextMenu {
}
}
- pub fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) {
- cx.emit(DismissEvent);
+ pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) {
+ if self.parent_menu.is_some() {
+ cx.emit(DismissEvent);
+
+ // Restore keyboard focus to the parent menu so arrow keys / Escape / Enter work again.
+ if let Some(parent) = &self.parent_menu {
+ let parent_focus = parent.read(cx).focus_handle.clone();
+
+ parent.update(cx, |parent, _cx| {
+ parent.ignore_blur_until = Some(Instant::now() + Duration::from_millis(200));
+ });
+
+ window.focus(&parent_focus, cx);
+ }
+
+ return;
+ }
+
cx.emit(DismissEvent);
}
@@ -773,6 +947,72 @@ impl ContextMenu {
self.handle_select_last(&SelectLast, window, cx);
}
+ pub fn select_submenu_child(
+ &mut self,
+ _: &SelectChild,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(ix) = self.selected_index else {
+ return;
+ };
+
+ let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) else {
+ return;
+ };
+
+ self.open_submenu(
+ ix,
+ builder.clone(),
+ SubmenuOpenTrigger::Keyboard,
+ window,
+ cx,
+ );
+
+ if let SubmenuState::Open(open_submenu) = &self.submenu_state {
+ let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
+ window.focus(&focus_handle, cx);
+ open_submenu.entity.update(cx, |submenu, cx| {
+ submenu.select_first(&SelectFirst, window, cx);
+ });
+ }
+
+ cx.notify();
+ }
+
+ pub fn select_submenu_parent(
+ &mut self,
+ _: &SelectParent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if self.parent_menu.is_none() {
+ return;
+ }
+
+ if let Some(parent) = &self.parent_menu {
+ let parent_clone = parent.clone();
+
+ let parent_focus = parent.read(cx).focus_handle.clone();
+ window.focus(&parent_focus, cx);
+
+ cx.emit(DismissEvent);
+
+ parent_clone.update(cx, |parent, cx| {
+ if let SubmenuState::Open(open_submenu) = &parent.submenu_state {
+ let trigger_index = open_submenu.item_index;
+ parent.close_submenu(false, cx);
+ let _ = parent.select_index(trigger_index, window, cx);
+ cx.notify();
+ }
+ });
+
+ return;
+ }
+
+ cx.emit(DismissEvent);
+ }
+
fn select_index(
&mut self,
ix: usize,
@@ -795,12 +1035,138 @@ impl ContextMenu {
} => {
self.documentation_aside = Some((ix, callback.clone()));
}
+ ContextMenuItem::Submenu { .. } => {}
_ => (),
}
}
Some(ix)
}
+ fn create_submenu(
+ builder: Rc) -> ContextMenu>,
+ parent_entity: Entity,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> (Entity, Subscription) {
+ let submenu = Self::build_submenu(builder, parent_entity, window, cx);
+
+ let dismiss_subscription = cx.subscribe(&submenu, |this, submenu, _: &DismissEvent, cx| {
+ let should_dismiss_parent = submenu.read(cx).clicked;
+
+ this.close_submenu(false, cx);
+
+ if should_dismiss_parent {
+ cx.emit(DismissEvent);
+ }
+ });
+
+ (submenu, dismiss_subscription)
+ }
+
+ fn build_submenu(
+ builder: Rc) -> ContextMenu>,
+ parent_entity: Entity,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Entity {
+ cx.new(|cx| {
+ let focus_handle = cx.focus_handle();
+
+ let _on_blur_subscription = cx.on_blur(
+ &focus_handle,
+ window,
+ |_this: &mut ContextMenu, _window, _cx| {},
+ );
+
+ let mut menu = ContextMenu {
+ builder: None,
+ items: Default::default(),
+ focus_handle,
+ action_context: None,
+ selected_index: None,
+ delayed: false,
+ clicked: false,
+ end_slot_action: None,
+ key_context: "menu".into(),
+ _on_blur_subscription,
+ keep_open_on_confirm: false,
+ documentation_aside: None,
+ fixed_width: None,
+ parent_menu: Some(parent_entity),
+ submenu_state: SubmenuState::Closed,
+ hover_target: HoverTarget::MainMenu,
+ submenu_safety_threshold_x: None,
+ submenu_observed_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_bounds: Rc::new(Cell::new(None)),
+ submenu_trigger_mouse_down: false,
+ ignore_blur_until: None,
+ };
+
+ menu = (builder)(menu, window, cx);
+ menu
+ })
+ }
+
+ fn close_submenu(&mut self, clear_selection: bool, cx: &mut Context) {
+ self.submenu_state = SubmenuState::Closed;
+ self.hover_target = HoverTarget::MainMenu;
+ self.submenu_safety_threshold_x = None;
+ self.submenu_observed_bounds.set(None);
+ self.submenu_trigger_bounds.set(None);
+
+ if clear_selection {
+ self.selected_index = None;
+ }
+
+ cx.notify();
+ }
+
+ fn open_submenu(
+ &mut self,
+ item_index: usize,
+ builder: Rc) -> ContextMenu>,
+ reason: SubmenuOpenTrigger,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ // If the submenu is already open for this item, don't recreate it.
+ if matches!(
+ &self.submenu_state,
+ SubmenuState::Open(open_submenu) if open_submenu.item_index == item_index
+ ) {
+ return;
+ }
+
+ let (submenu, dismiss_subscription) =
+ Self::create_submenu(builder, cx.entity(), window, cx);
+
+ // If we're switching from one submenu item to another, throw away any previously-captured
+ // offset so we don't reuse a stale position.
+ self.submenu_observed_bounds.set(None);
+ self.submenu_trigger_bounds.set(None);
+
+ self.submenu_safety_threshold_x = None;
+ self.hover_target = HoverTarget::MainMenu;
+
+ // When opening a submenu via keyboard, there is a brief moment where focus/hover can
+ // transition in a way that triggers the parent menu's `on_blur` dismissal.
+ if matches!(reason, SubmenuOpenTrigger::Keyboard) {
+ self.ignore_blur_until = Some(Instant::now() + Duration::from_millis(150));
+ }
+
+ let trigger_bounds = self.submenu_trigger_bounds.get();
+
+ self.submenu_state = SubmenuState::Open(OpenSubmenu {
+ item_index,
+ entity: submenu,
+ trigger_bounds,
+ offset: None,
+ _dismiss_subscription: dismiss_subscription,
+ });
+
+ cx.notify();
+ }
+
pub fn on_action_dispatch(
&mut self,
dispatched: &dyn Action,
@@ -917,11 +1283,7 @@ impl ContextMenu {
.child(
ListItem::new(ix)
.inset(true)
- .toggle_state(if selectable {
- Some(ix) == self.selected_index
- } else {
- false
- })
+ .toggle_state(Some(ix) == self.selected_index)
.selectable(selectable)
.when(selectable, |item| {
item.on_click({
@@ -946,9 +1308,210 @@ impl ContextMenu {
)
.into_any_element()
}
+ ContextMenuItem::Submenu { label, icon, .. } => self
+ .render_submenu_item_trigger(ix, label.clone(), *icon, cx)
+ .into_any_element(),
}
}
+ fn render_submenu_item_trigger(
+ &self,
+ ix: usize,
+ label: SharedString,
+ icon: Option,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let toggle_state = Some(ix) == self.selected_index
+ || matches!(
+ &self.submenu_state,
+ SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
+ );
+
+ div()
+ .id(("context-menu-submenu-trigger", ix))
+ .capture_any_mouse_down(cx.listener(move |this, event: &MouseDownEvent, _, _| {
+ // This prevents on_hover(false) from closing the submenu during a click.
+ if event.button == MouseButton::Left {
+ this.submenu_trigger_mouse_down = true;
+ }
+ }))
+ .capture_any_mouse_up(cx.listener(move |this, event: &MouseUpEvent, _, _| {
+ if event.button == MouseButton::Left {
+ this.submenu_trigger_mouse_down = false;
+ }
+ }))
+ .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| {
+ if matches!(&this.submenu_state, SubmenuState::Open(_))
+ || this.selected_index == Some(ix)
+ {
+ this.submenu_safety_threshold_x = Some(event.position.x - px(100.0));
+ }
+
+ cx.notify();
+ }))
+ .child(
+ ListItem::new(ix)
+ .inset(true)
+ .toggle_state(toggle_state)
+ .child(
+ canvas(
+ {
+ let trigger_bounds_cell = self.submenu_trigger_bounds.clone();
+ move |bounds, _window, _cx| {
+ if toggle_state {
+ trigger_bounds_cell.set(Some(bounds));
+ }
+ }
+ },
+ |_bounds, _state, _window, _cx| {},
+ )
+ .size_full()
+ .absolute()
+ .top_0()
+ .left_0(),
+ )
+ .on_hover(cx.listener(move |this, hovered, window, cx| {
+ let mouse_pos = window.mouse_position();
+
+ if *hovered {
+ this.clear_selected();
+ window.focus(&this.focus_handle.clone(), cx);
+ this.hover_target = HoverTarget::MainMenu;
+ this.submenu_safety_threshold_x = Some(mouse_pos.x - px(50.0));
+
+ if let Some(ContextMenuItem::Submenu { builder, .. }) =
+ this.items.get(ix)
+ {
+ this.open_submenu(
+ ix,
+ builder.clone(),
+ SubmenuOpenTrigger::Pointer,
+ window,
+ cx,
+ );
+ }
+
+ cx.notify();
+ } else {
+ if this.submenu_trigger_mouse_down {
+ return;
+ }
+
+ let is_open_for_this_item = matches!(
+ &this.submenu_state,
+ SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
+ );
+
+ if is_open_for_this_item && this.hover_target != HoverTarget::Submenu {
+ this.close_submenu(false, cx);
+ this.clear_selected();
+ cx.notify();
+ }
+ }
+ }))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ if matches!(
+ &this.submenu_state,
+ SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
+ ) {
+ return;
+ }
+
+ if let Some(ContextMenuItem::Submenu { builder, .. }) = this.items.get(ix) {
+ this.open_submenu(
+ ix,
+ builder.clone(),
+ SubmenuOpenTrigger::Pointer,
+ window,
+ cx,
+ );
+ }
+ }))
+ .child(
+ h_flex()
+ .w_full()
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_1p5()
+ .when_some(icon, |this, icon_name| {
+ this.child(
+ Icon::new(icon_name)
+ .size(IconSize::Small)
+ .color(Color::Default),
+ )
+ })
+ .child(Label::new(label).color(Color::Default)),
+ )
+ .child(
+ Icon::new(IconName::ChevronRight)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
+ ),
+ )
+ }
+
+ fn padded_submenu_bounds(&self) -> Option> {
+ let bounds = self.submenu_observed_bounds.get()?;
+ Some(Bounds {
+ origin: Point {
+ x: bounds.origin.x - px(50.0),
+ y: bounds.origin.y - px(50.0),
+ },
+ size: Size {
+ width: bounds.size.width + px(100.0),
+ height: bounds.size.height + px(100.0),
+ },
+ })
+ }
+
+ fn render_submenu_container(
+ &self,
+ ix: usize,
+ submenu: Entity,
+ offset: Pixels,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let bounds_cell = self.submenu_observed_bounds.clone();
+ let canvas = canvas(
+ {
+ move |bounds, _window, _cx| {
+ bounds_cell.set(Some(bounds));
+ }
+ },
+ |_bounds, _state, _window, _cx| {},
+ )
+ .size_full()
+ .absolute()
+ .top_0()
+ .left_0();
+
+ div()
+ .id(("submenu-container", ix))
+ .absolute()
+ .left_full()
+ .ml_neg_0p5()
+ .top(offset)
+ .on_hover(cx.listener(|this, hovered, _, _| {
+ if *hovered {
+ this.hover_target = HoverTarget::Submenu;
+ }
+ }))
+ .child(
+ anchored()
+ .anchor(Corner::TopLeft)
+ .snap_to_window_with_margin(px(8.0))
+ .child(
+ div()
+ .id(("submenu-hover-zone", ix))
+ .occlude()
+ .child(canvas)
+ .child(submenu),
+ ),
+ )
+ }
+
fn render_menu_entry(
&self,
ix: usize,
@@ -1074,6 +1637,81 @@ impl ContextMenu {
.inset(true)
.disabled(*disabled)
.toggle_state(Some(ix) == self.selected_index)
+ .when(self.parent_menu.is_none() && !*disabled, |item| {
+ item.on_hover(cx.listener(move |this, hovered, window, cx| {
+ if *hovered {
+ this.clear_selected();
+ window.focus(&this.focus_handle.clone(), cx);
+
+ if let SubmenuState::Open(open_submenu) = &this.submenu_state {
+ if open_submenu.item_index != ix {
+ this.close_submenu(false, cx);
+ cx.notify();
+ }
+ }
+ }
+ }))
+ })
+ .when(self.parent_menu.is_some(), |item| {
+ item.on_click(cx.listener(move |this, _, window, cx| {
+ if matches!(
+ &this.submenu_state,
+ SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
+ ) {
+ return;
+ }
+
+ if let Some(ContextMenuItem::Submenu { builder, .. }) =
+ this.items.get(ix)
+ {
+ this.open_submenu(
+ ix,
+ builder.clone(),
+ SubmenuOpenTrigger::Pointer,
+ window,
+ cx,
+ );
+ }
+ }))
+ .on_hover(cx.listener(
+ move |this, hovered, window, cx| {
+ if *hovered {
+ this.clear_selected();
+ cx.notify();
+ }
+
+ if let Some(parent) = &this.parent_menu {
+ let mouse_pos = window.mouse_position();
+ let parent_clone = parent.clone();
+
+ if *hovered {
+ parent.update(cx, |parent, _| {
+ parent.clear_selected();
+ parent.hover_target = HoverTarget::Submenu;
+ });
+ } else {
+ parent_clone.update(cx, |parent, cx| {
+ if matches!(
+ &parent.submenu_state,
+ SubmenuState::Open(_)
+ ) {
+ // Only close if mouse is to the left of the safety threshold
+ // (prevents accidental close when moving diagonally toward submenu)
+ let should_close = parent
+ .submenu_safety_threshold_x
+ .map(|threshold_x| mouse_pos.x < threshold_x)
+ .unwrap_or(true);
+
+ if should_close {
+ parent.close_submenu(true, cx);
+ }
+ }
+ });
+ }
+ }
+ },
+ ))
+ })
.when_some(*toggle, |list_item, (position, toggled)| {
let contents = div()
.flex_none()
@@ -1200,6 +1838,7 @@ impl ContextMenuItem {
| ContextMenuItem::Label { .. } => false,
ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
+ ContextMenuItem::Submenu { .. } => true,
}
}
}
@@ -1211,6 +1850,42 @@ impl Render for ContextMenu {
let rem_size = window.rem_size();
let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
+ let mut focus_submenu: Option = None;
+
+ let submenu_container = match &mut self.submenu_state {
+ SubmenuState::Open(open_submenu) => {
+ let is_initializing = open_submenu.offset.is_none();
+
+ let computed_offset = if is_initializing {
+ let menu_bounds = self.submenu_observed_bounds.get();
+ let trigger_bounds = open_submenu
+ .trigger_bounds
+ .or_else(|| self.submenu_trigger_bounds.get());
+
+ match (menu_bounds, trigger_bounds) {
+ (Some(menu_bounds), Some(trigger_bounds)) => {
+ Some(trigger_bounds.origin.y - menu_bounds.origin.y)
+ }
+ _ => None,
+ }
+ } else {
+ None
+ };
+
+ if let Some(offset) = open_submenu.offset.or(computed_offset) {
+ if open_submenu.offset.is_none() {
+ open_submenu.offset = Some(offset);
+ }
+
+ focus_submenu = Some(open_submenu.entity.read(cx).focus_handle.clone());
+ Some((open_submenu.item_index, open_submenu.entity.clone(), offset))
+ } else {
+ None
+ }
+ }
+ _ => None,
+ };
+
let aside = self.documentation_aside.clone();
let render_aside = |aside: DocumentationAside, cx: &mut Context| {
WithRemSize::new(ui_font_size)
@@ -1224,65 +1899,112 @@ impl Render for ContextMenu {
.child((aside.render)(cx))
};
- let render_menu =
- |cx: &mut Context, window: &mut Window| {
- WithRemSize::new(ui_font_size)
- .occlude()
- .elevation_2(cx)
- .flex()
- .flex_row()
- .flex_shrink_0()
- .child(
- v_flex()
- .id("context-menu")
- .max_h(vh(0.75, window))
- .flex_shrink_0()
- .when_some(self.fixed_width, |this, width| {
- this.w(width).overflow_x_hidden()
- })
- .when(self.fixed_width.is_none(), |this| {
- this.min_w(px(200.)).flex_1()
- })
- .overflow_y_scroll()
- .track_focus(&self.focus_handle(cx))
- .on_mouse_down_out(cx.listener(|this, _, window, cx| {
- this.cancel(&menu::Cancel, window, cx)
- }))
- .key_context(self.key_context.as_ref())
- .on_action(cx.listener(ContextMenu::select_first))
- .on_action(cx.listener(ContextMenu::handle_select_last))
- .on_action(cx.listener(ContextMenu::select_next))
- .on_action(cx.listener(ContextMenu::select_previous))
- .on_action(cx.listener(ContextMenu::confirm))
- .on_action(cx.listener(ContextMenu::cancel))
- .when_some(self.end_slot_action.as_ref(), |el, action| {
- el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
- })
- .when(!self.delayed, |mut el| {
- for item in self.items.iter() {
- if let ContextMenuItem::Entry(ContextMenuEntry {
- action: Some(action),
- disabled: false,
- ..
- }) = item
- {
- el = el.on_boxed_action(
- &**action,
- cx.listener(ContextMenu::on_action_dispatch),
- );
+ let render_menu = |cx: &mut Context, window: &mut Window| {
+ let bounds_cell = self.submenu_observed_bounds.clone();
+ let menu_bounds_measure = canvas(
+ {
+ move |bounds, _window, _cx| {
+ bounds_cell.set(Some(bounds));
+ }
+ },
+ |_bounds, _state, _window, _cx| {},
+ )
+ .size_full()
+ .absolute()
+ .top_0()
+ .left_0();
+
+ WithRemSize::new(ui_font_size)
+ .occlude()
+ .elevation_2(cx)
+ .flex()
+ .flex_row()
+ .flex_shrink_0()
+ .child(
+ v_flex()
+ .id("context-menu")
+ .max_h(vh(0.75, window))
+ .flex_shrink_0()
+ .child(menu_bounds_measure)
+ .when_some(self.fixed_width, |this, width| {
+ this.w(width).overflow_x_hidden()
+ })
+ .when(self.fixed_width.is_none(), |this| {
+ this.min_w(px(200.)).flex_1()
+ })
+ .overflow_y_scroll()
+ .track_focus(&self.focus_handle(cx))
+ .key_context(self.key_context.as_ref())
+ .on_action(cx.listener(ContextMenu::select_first))
+ .on_action(cx.listener(ContextMenu::handle_select_last))
+ .on_action(cx.listener(ContextMenu::select_next))
+ .on_action(cx.listener(ContextMenu::select_previous))
+ .on_action(cx.listener(ContextMenu::select_submenu_child))
+ .on_action(cx.listener(ContextMenu::select_submenu_parent))
+ .on_action(cx.listener(ContextMenu::confirm))
+ .on_action(cx.listener(ContextMenu::cancel))
+ .on_hover(cx.listener(|this, hovered: &bool, _, _| {
+ if *hovered {
+ this.hover_target = HoverTarget::MainMenu;
+ }
+ }))
+ .on_mouse_down_out(cx.listener(
+ |this, event: &MouseDownEvent, window, cx| {
+ if matches!(&this.submenu_state, SubmenuState::Open(_)) {
+ if let Some(padded_bounds) = this.padded_submenu_bounds() {
+ if padded_bounds.contains(&event.position) {
+ return;
+ }
}
}
- el
- })
- .child(
- List::new().children(
- self.items.iter().enumerate().map(|(ix, item)| {
- self.render_menu_item(ix, item, window, cx)
- }),
- ),
+
+ if let Some(parent) = &this.parent_menu {
+ let overridden_by_parent_trigger = parent
+ .read(cx)
+ .submenu_trigger_bounds
+ .get()
+ .is_some_and(|bounds| bounds.contains(&event.position));
+ if overridden_by_parent_trigger {
+ return;
+ }
+ }
+
+ this.cancel(&menu::Cancel, window, cx)
+ },
+ ))
+ .when_some(self.end_slot_action.as_ref(), |el, action| {
+ el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
+ })
+ .when(!self.delayed, |mut el| {
+ for item in self.items.iter() {
+ if let ContextMenuItem::Entry(ContextMenuEntry {
+ action: Some(action),
+ disabled: false,
+ ..
+ }) = item
+ {
+ el = el.on_boxed_action(
+ &**action,
+ cx.listener(ContextMenu::on_action_dispatch),
+ );
+ }
+ }
+ el
+ })
+ .child(
+ List::new().children(
+ self.items
+ .iter()
+ .enumerate()
+ .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
),
- )
- };
+ ),
+ )
+ };
+
+ if let Some(focus_handle) = focus_submenu.as_ref() {
+ window.focus(focus_handle, cx);
+ }
if is_wide_window {
div()
@@ -1303,13 +2025,20 @@ impl Render for ContextMenu {
})
.child(render_aside(aside, cx))
}))
+ .when_some(submenu_container, |this, (ix, submenu, offset)| {
+ this.child(self.render_submenu_container(ix, submenu, offset, cx))
+ })
} else {
v_flex()
.w_full()
+ .relative()
.gap_1()
.justify_end()
.children(aside.map(|(_, aside)| render_aside(aside, cx)))
.child(render_menu(cx, window))
+ .when_some(submenu_container, |this, (ix, submenu, offset)| {
+ this.child(self.render_submenu_container(ix, submenu, offset, cx))
+ })
}
}
}
diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs
index 5b5de7a257..7a1d3c7dfd 100644
--- a/crates/ui/src/components/dropdown_menu.rs
+++ b/crates/ui/src/components/dropdown_menu.rs
@@ -247,6 +247,30 @@ impl Component for DropdownMenu {
.entry("Option 4", None, |_, _| {})
});
+ let menu_with_submenu = ContextMenu::build(window, cx, |this, _, _| {
+ this.entry("Toggle All Docks", None, |_, _| {})
+ .submenu("Editor Layout", |menu, _, _| {
+ menu.entry("Split Up", None, |_, _| {})
+ .entry("Split Down", None, |_, _| {})
+ .separator()
+ .entry("Split Side", None, |_, _| {})
+ })
+ .separator()
+ .entry("Project Panel", None, |_, _| {})
+ .entry("Outline Panel", None, |_, _| {})
+ .separator()
+ .submenu("Autofill", |menu, _, _| {
+ menu.entry("Contact…", None, |_, _| {})
+ .entry("Passwords…", None, |_, _| {})
+ })
+ .submenu_with_icon("Predict", IconName::ZedPredict, |menu, _, _| {
+ menu.entry("Everywhere", None, |_, _| {})
+ .entry("At Cursor", None, |_, _| {})
+ .entry("Over Here", None, |_, _| {})
+ .entry("Over There", None, |_, _| {})
+ })
+ });
+
Some(
v_flex()
.gap_6()
@@ -271,6 +295,14 @@ impl Component for DropdownMenu {
),
],
),
+ example_group_with_title(
+ "Submenus",
+ vec![single_example(
+ "With Submenus",
+ DropdownMenu::new("submenu", "Submenu", menu_with_submenu)
+ .into_any_element(),
+ )],
+ ),
example_group_with_title(
"Styles",
vec![
| |