diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index e2a0bd89b54110209777857e8690ab123d92e3bb..1abe2be13dff62261203de3c59cc39c243da6fe4 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 2524d98e3778684080775f00a82d0764bfd0361b..73c3181b63663dce1bd680f28e39bfbd4a013746 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 039ae408219909006e5d84bb12822444330acd83..7f1869e5775d628ea65e68d5d76472ac7ec5acff 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 9a1937d100210cb975ab0630be9fd15078561e0b..afa361ba38d9c7a8a5c2350dee7bf573bcefe46a 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 7e5e9032c9d4b0521f972b47d90d24cd502faf7b..119ce6bcb6c8436303c5c2f234cb69b4309bda41 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -1,17 +1,70 @@ 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, MouseDownEvent, MouseMoveEvent, 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, + _dismiss_subscription: Subscription, +} + +enum SubmenuState { + Closed, + Open(OpenSubmenu), +} + +struct SubmenuHoverSafetyHeuristic { + last_mouse_position: Option>, + trigger_left_x: Option, +} + +impl SubmenuHoverSafetyHeuristic { + fn new() -> Self { + Self { + last_mouse_position: None, + trigger_left_x: None, + } + } + + fn clear(&mut self) { + self.last_mouse_position = None; + self.trigger_left_x = None; + } + + fn update_mouse_position(&mut self, position: Point) { + self.last_mouse_position = Some(position); + } + + fn update_trigger_left_x(&mut self, trigger_left_x: Pixels) { + self.trigger_left_x = Some(trigger_left_x); + } + + fn should_allow_close_from_parent_area(&self, mouse_position: Point) -> bool { + self.trigger_left_x + .map(|trigger_left_x| mouse_position.x < trigger_left_x) + .unwrap_or(true) + } +} pub enum ContextMenuItem { Separator, @@ -26,6 +79,11 @@ pub enum ContextMenuItem { selectable: bool, documentation_aside: Option, }, + Submenu { + label: SharedString, + icon: Option, + builder: Rc) -> ContextMenu>, + }, } impl ContextMenuItem { @@ -181,6 +239,16 @@ pub struct ContextMenu { keep_open_on_confirm: bool, documentation_aside: Option<(usize, DocumentationAside)>, fixed_width: Option, + // Submenu-related fields + submenu_state: SubmenuState, + submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic, + submenu_observed_bounds: Rc>>>, + is_submenu: bool, + submenu_hovered: bool, + submenu_generation: u64, + ignore_blur_cancel_until: Option, + parent_menu: Option>, + menu_hovered: bool, } #[derive(Copy, Clone, PartialEq, Eq)] @@ -233,7 +301,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_cancel_until { + if Instant::now() < ignore_until { + return; + } else { + this.ignore_blur_cancel_until = None; + } + } + + if !this.is_submenu { + 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 +333,21 @@ 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, + submenu_state: SubmenuState::Closed, + submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(), + submenu_observed_bounds: Rc::new(Cell::new(None)), + is_submenu: false, + submenu_hovered: false, + submenu_generation: 0, + ignore_blur_cancel_until: None, + parent_menu: None, + menu_hovered: true, }, window, cx, @@ -282,7 +378,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_cancel_until { + if Instant::now() < ignore_until { + return; + } else { + this.ignore_blur_cancel_until = None; + } + } + + if !this.is_submenu { + 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 +410,21 @@ 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, + submenu_state: SubmenuState::Closed, + submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(), + submenu_observed_bounds: Rc::new(Cell::new(None)), + is_submenu: false, + submenu_hovered: false, + submenu_generation: 0, + ignore_blur_cancel_until: None, + parent_menu: None, + menu_hovered: true, }, window, cx, @@ -331,16 +455,45 @@ 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_cancel_until { + if Instant::now() < ignore_until { + return; + } else { + this.ignore_blur_cancel_until = None; + } + } + + if !this.is_submenu { + 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, + submenu_state: SubmenuState::Closed, + submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(), + submenu_observed_bounds: Rc::new(Cell::new(None)), + is_submenu: false, + submenu_hovered: false, + submenu_generation: 0, + ignore_blur_cancel_until: None, + parent_menu: None, + menu_hovered: true, }, window, cx, @@ -636,6 +789,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 +863,10 @@ impl ContextMenu { (handler)(context, window, cx) } + if self.is_submenu && !self.keep_open_on_confirm { + self.clicked = true; + } + if self.keep_open_on_confirm { self.rebuild(window, cx); } else { @@ -690,8 +874,25 @@ 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.is_submenu { + 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_cancel_until = + Some(Instant::now() + Duration::from_millis(200)); + }); + + window.focus(&parent_focus, cx); + } + + return; + } + cx.emit(DismissEvent); } @@ -773,6 +974,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.is_submenu { + 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 +1062,127 @@ 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, + submenu_state: SubmenuState::Closed, + submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(), + submenu_observed_bounds: Rc::new(Cell::new(None)), + is_submenu: true, + submenu_hovered: false, + submenu_generation: 0, + ignore_blur_cancel_until: None, + parent_menu: Some(parent_entity), + menu_hovered: true, + }; + + menu = (builder)(menu, window, cx); + menu + }) + } + + fn close_submenu(&mut self, clear_selection: bool, cx: &mut Context) { + self.submenu_generation = self.submenu_generation.wrapping_add(1); + self.submenu_state = SubmenuState::Closed; + self.submenu_hovered = false; + self.submenu_hover_safety_heuristic.clear(); + self.submenu_observed_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, + ) { + let (submenu, dismiss_subscription) = + Self::create_submenu(builder, cx.entity().clone(), window, cx); + + self.submenu_observed_bounds.set(None); + self.submenu_hover_safety_heuristic.clear(); + self.submenu_hovered = false; + + let _ = reason; + + let submenu_focus = submenu.read(cx).focus_handle.clone(); + + self.submenu_state = SubmenuState::Open(OpenSubmenu { + item_index, + entity: submenu, + _dismiss_subscription: dismiss_subscription, + }); + + window.focus(&submenu_focus, cx); + cx.notify(); + } + + fn update_last_mouse_position(&mut self, position: Point) { + self.submenu_hover_safety_heuristic + .update_mouse_position(position); + } + pub fn on_action_dispatch( &mut self, dispatched: &dyn Action, @@ -917,11 +1299,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,7 +1324,189 @@ 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)) + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + this.update_last_mouse_position(event.position); + + if matches!(&this.submenu_state, SubmenuState::Open(_)) + || this.selected_index == Some(ix) + { + this.submenu_hover_safety_heuristic + .update_trigger_left_x(event.position.x - px(100.0)); + } + + cx.notify(); + })) + .child( + ListItem::new(ix) + .inset(true) + .toggle_state(toggle_state) + .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.menu_hovered = true; + this.submenu_hover_safety_heuristic + .update_trigger_left_x(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 { + 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.submenu_hovered { + this.close_submenu(false, cx); + this.clear_selected(); + cx.notify(); + } + } + })) + .on_click(cx.listener(move |this, _, window, cx| { + 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_for(&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 calculate_submenu_offset(&self, item_index: usize) -> Pixels { + let list_item_height = px(28.); + let separator_height = px(9.); + + let mut offset = px(0.0); + + for (ix, item) in self.items.iter().enumerate() { + if ix >= item_index { + break; + } + match item { + ContextMenuItem::Separator => offset += separator_height, + _ => offset += list_item_height, + } } + offset + } + + 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( + { + let bounds_cell = bounds_cell.clone(); + 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)) + .on_hover(cx.listener(|this, _, _, _| { + this.submenu_hovered = true; + })) + .absolute() + .left_full() + .top(offset) + .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( @@ -1074,6 +1634,71 @@ impl ContextMenu { .inset(true) .disabled(*disabled) .toggle_state(Some(ix) == self.selected_index) + .when(!self.is_submenu && !*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.is_submenu, |item| { + item.on_click(cx.listener(move |this, _, window, cx| { + 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.submenu_hovered = true; + }); + } else { + parent_clone.update(cx, |parent, cx| { + if matches!( + &parent.submenu_state, + SubmenuState::Open(_) + ) { + let should_close = parent + .submenu_hover_safety_heuristic + .should_allow_close_from_parent_area(mouse_pos); + + if should_close { + parent.close_submenu(true, cx); + } + } + }); + } + } + }, + )) + }) .when_some(*toggle, |list_item, (position, toggled)| { let contents = div() .flex_none() @@ -1200,6 +1825,7 @@ impl ContextMenuItem { | ContextMenuItem::Label { .. } => false, ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled, ContextMenuItem::CustomEntry { selectable, .. } => *selectable, + ContextMenuItem::Submenu { .. } => true, } } } @@ -1211,6 +1837,14 @@ 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 submenu_container = match &self.submenu_state { + SubmenuState::Open(open_submenu) => { + let offset = self.calculate_submenu_offset(open_submenu.item_index); + Some((open_submenu.item_index, open_submenu.entity.clone(), offset)) + } + _ => None, + }; + let aside = self.documentation_aside.clone(); let render_aside = |aside: DocumentationAside, cx: &mut Context| { WithRemSize::new(ui_font_size) @@ -1224,65 +1858,80 @@ 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| { + 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)) + .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, _, _| { + this.menu_hovered = *hovered; + })) + .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_for() { + 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) - }), - ), + + 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 is_wide_window { div() @@ -1303,13 +1952,20 @@ impl Render for ContextMenu { }) .child(render_aside(aside, cx)) })) + .when_some(submenu_container.clone(), |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 5b5de7a257ad22f099b8c2c4cc81aa83d2841976..7a1d3c7dfd77306b2d7b3b6786dae04d6eaee6b2 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![