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![