ui: Add submenus to ContextMenu (#45743)
This PR introduces submenu functionality for Zed's dropdown/context menus. <img width="600" height="830" alt="Screenshot 2025-12-27 at 2 03@2x" src="https://github.com/user-attachments/assets/eadfcd74-07fe-4a1f-be76-11b547c16dc8" /> ```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>
This commit is contained in:
@@ -55,6 +55,13 @@
|
||||
"down": "menu::SelectNext",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "menu",
|
||||
"bindings": {
|
||||
"right": "menu::SelectChild",
|
||||
"left": "menu::SelectParent",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "Editor",
|
||||
"bindings": {
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
"ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "menu",
|
||||
"bindings": {
|
||||
"right": "menu::SelectChild",
|
||||
"left": "menu::SelectParent",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "Editor",
|
||||
"use_key_equivalents": true,
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
"down": "menu::SelectNext",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "menu",
|
||||
"bindings": {
|
||||
"right": "menu::SelectChild",
|
||||
"left": "menu::SelectParent",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "Editor",
|
||||
"use_key_equivalents": true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ContextMenu>,
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
// Capture the submenu's vertical offset once and keep it stable while the submenu is open.
|
||||
offset: Option<Pixels>,
|
||||
_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<DocumentationAside>,
|
||||
},
|
||||
Submenu {
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContextMenuItem {
|
||||
@@ -181,6 +217,15 @@ pub struct ContextMenu {
|
||||
keep_open_on_confirm: bool,
|
||||
documentation_aside: Option<(usize, DocumentationAside)>,
|
||||
fixed_width: Option<DefiniteLength>,
|
||||
// Submenu-related fields
|
||||
parent_menu: Option<Entity<ContextMenu>>,
|
||||
submenu_state: SubmenuState,
|
||||
hover_target: HoverTarget,
|
||||
submenu_safety_threshold_x: Option<Pixels>,
|
||||
submenu_observed_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
submenu_trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
submenu_trigger_mouse_down: bool,
|
||||
ignore_blur_until: Option<Instant>,
|
||||
}
|
||||
|
||||
#[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<SharedString>,
|
||||
builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> 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<SharedString>,
|
||||
icon: IconName,
|
||||
builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> 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<Self>) {
|
||||
pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
|
||||
parent_entity: Entity<ContextMenu>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> (Entity<ContextMenu>, 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<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
|
||||
parent_entity: Entity<ContextMenu>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<ContextMenu> {
|
||||
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>) {
|
||||
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<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
|
||||
reason: SubmenuOpenTrigger,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// 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<IconName>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Bounds<Pixels>> {
|
||||
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<ContextMenu>,
|
||||
offset: Pixels,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<FocusHandle> = 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<Self>| {
|
||||
WithRemSize::new(ui_font_size)
|
||||
@@ -1224,8 +1899,21 @@ impl Render for ContextMenu {
|
||||
.child((aside.render)(cx))
|
||||
};
|
||||
|
||||
let render_menu =
|
||||
|cx: &mut Context<Self>, window: &mut Window| {
|
||||
let render_menu = |cx: &mut Context<Self>, 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)
|
||||
@@ -1237,6 +1925,7 @@ impl Render for ContextMenu {
|
||||
.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()
|
||||
})
|
||||
@@ -1245,16 +1934,44 @@ impl Render for ContextMenu {
|
||||
})
|
||||
.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::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
@@ -1276,14 +1993,19 @@ impl Render for ContextMenu {
|
||||
})
|
||||
.child(
|
||||
List::new().children(
|
||||
self.items.iter().enumerate().map(|(ix, item)| {
|
||||
self.render_menu_item(ix, item, window, cx)
|
||||
}),
|
||||
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()
|
||||
.relative()
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user