More attachment configuration for context menus

Conrad Irwin created

Change summary

crates/gpui2/src/elements/overlay.rs       |  21 +
crates/gpui2/src/window.rs                 |   8 
crates/terminal_view2/src/terminal_view.rs |   2 
crates/ui2/src/components/context_menu.rs  | 317 +++++++++++++++--------
crates/ui2/src/components/icon_button.rs   |  17 +
crates/workspace2/src/dock.rs              |  34 +
6 files changed, 267 insertions(+), 132 deletions(-)

Detailed changes

crates/gpui2/src/elements/overlay.rs 🔗

@@ -15,7 +15,7 @@ pub struct Overlay<V> {
     anchor_corner: AnchorCorner,
     fit_mode: OverlayFitMode,
     // todo!();
-    // anchor_position: Option<Vector2F>,
+    anchor_position: Option<Point<Pixels>>,
     // position_mode: OverlayPositionMode,
 }
 
@@ -26,6 +26,7 @@ pub fn overlay<V: 'static>() -> Overlay<V> {
         children: SmallVec::new(),
         anchor_corner: AnchorCorner::TopLeft,
         fit_mode: OverlayFitMode::SwitchAnchor,
+        anchor_position: None,
     }
 }
 
@@ -36,6 +37,13 @@ impl<V> Overlay<V> {
         self
     }
 
+    /// Sets the position in window co-ordinates
+    /// (otherwise the location the overlay is rendered is used)
+    pub fn position(mut self, anchor: Point<Pixels>) -> Self {
+        self.anchor_position = Some(anchor);
+        self
+    }
+
     /// Snap to window edge instead of switching anchor corner when an overflow would occur.
     pub fn snap_to_window(mut self) -> Self {
         self.fit_mode = OverlayFitMode::SnapToWindow;
@@ -102,7 +110,7 @@ impl<V: 'static> Element<V> for Overlay<V> {
             child_max = child_max.max(&child_bounds.lower_right());
         }
         let size: Size<Pixels> = (child_max - child_min).into();
-        let origin = bounds.origin;
+        let origin = self.anchor_position.unwrap_or(bounds.origin);
 
         let mut desired = self.anchor_corner.get_bounds(origin, size);
         let limits = Bounds {
@@ -196,6 +204,15 @@ impl AnchorCorner {
         Bounds { origin, size }
     }
 
+    pub fn corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
+        match self {
+            Self::TopLeft => bounds.origin,
+            Self::TopRight => bounds.upper_right(),
+            Self::BottomLeft => bounds.lower_left(),
+            Self::BottomRight => bounds.lower_right(),
+        }
+    }
+
     fn switch_axis(self, axis: Axis) -> Self {
         match axis {
             Axis::Vertical => match self {

crates/gpui2/src/window.rs 🔗

@@ -1151,6 +1151,14 @@ impl<'a> WindowContext<'a> {
                 self.window.mouse_position = mouse_move.position;
                 InputEvent::MouseMove(mouse_move)
             }
+            InputEvent::MouseDown(mouse_down) => {
+                self.window.mouse_position = mouse_down.position;
+                InputEvent::MouseDown(mouse_down)
+            }
+            InputEvent::MouseUp(mouse_up) => {
+                self.window.mouse_position = mouse_up.position;
+                InputEvent::MouseUp(mouse_up)
+            }
             // Translate dragging and dropping of external files from the operating system
             // to internal drag and drop events.
             InputEvent::FileDrop(file_drop) => match file_drop {

crates/terminal_view2/src/terminal_view.rs 🔗

@@ -32,7 +32,7 @@ use workspace::{
     notifications::NotifyResultExt,
     register_deserializable_item,
     searchable::{SearchEvent, SearchOptions, SearchableItem},
-    ui::{ContextMenu, ContextMenuItem, Label},
+    ui::{ContextMenu, Label},
     CloseActiveItem, NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId,
 };
 

crates/ui2/src/components/context_menu.rs 🔗

@@ -1,56 +1,14 @@
 use std::cell::RefCell;
 use std::rc::Rc;
 
-use crate::{prelude::*, ListItemVariant};
+use crate::prelude::*;
 use crate::{v_stack, Label, List, ListEntry, ListItem, ListSeparator, ListSubHeader};
 use gpui::{
-    overlay, px, Action, AnyElement, Bounds, DispatchPhase, EventEmitter, FocusHandle,
-    FocusableView, LayoutId, MouseButton, MouseDownEvent, Overlay, Render, View,
+    overlay, px, Action, AnchorCorner, AnyElement, Bounds, DispatchPhase, Div, EventEmitter,
+    FocusHandle, FocusableView, LayoutId, MouseButton, MouseDownEvent, Pixels, Point, Render, View,
 };
 use smallvec::SmallVec;
 
-pub enum ContextMenuItem {
-    Header(SharedString),
-    Entry(Label, Box<dyn gpui::Action>),
-    Separator,
-}
-
-impl Clone for ContextMenuItem {
-    fn clone(&self) -> Self {
-        match self {
-            ContextMenuItem::Header(name) => ContextMenuItem::Header(name.clone()),
-            ContextMenuItem::Entry(label, action) => {
-                ContextMenuItem::Entry(label.clone(), action.boxed_clone())
-            }
-            ContextMenuItem::Separator => ContextMenuItem::Separator,
-        }
-    }
-}
-impl ContextMenuItem {
-    fn to_list_item(self) -> ListItem {
-        match self {
-            ContextMenuItem::Header(label) => ListSubHeader::new(label).into(),
-            ContextMenuItem::Entry(label, action) => ListEntry::new(label)
-                .variant(ListItemVariant::Inset)
-                .action(action)
-                .into(),
-            ContextMenuItem::Separator => ListSeparator::new().into(),
-        }
-    }
-
-    pub fn header(label: impl Into<SharedString>) -> Self {
-        Self::Header(label.into())
-    }
-
-    pub fn separator() -> Self {
-        Self::Separator
-    }
-
-    pub fn entry(label: Label, action: impl Action) -> Self {
-        Self::Entry(label, Box::new(action))
-    }
-}
-
 pub struct ContextMenu {
     items: Vec<ListItem>,
     focus_handle: FocusHandle,
@@ -101,67 +59,93 @@ impl ContextMenu {
 }
 
 impl Render for ContextMenu {
-    type Element = Overlay<Self>;
+    type Element = Div<Self>;
     // todo!()
     fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
-        overlay().child(
-            div().elevation_2(cx).flex().flex_row().child(
-                v_stack()
-                    .min_w(px(200.))
-                    .track_focus(&self.focus_handle)
-                    .on_mouse_down_out(|this: &mut Self, _, cx| {
-                        this.cancel(&Default::default(), cx)
-                    })
-                    // .on_action(ContextMenu::select_first)
-                    // .on_action(ContextMenu::select_last)
-                    // .on_action(ContextMenu::select_next)
-                    // .on_action(ContextMenu::select_prev)
-                    .on_action(ContextMenu::confirm)
-                    .on_action(ContextMenu::cancel)
-                    .flex_none()
-                    // .bg(cx.theme().colors().elevated_surface_background)
-                    // .border()
-                    // .border_color(cx.theme().colors().border)
-                    .child(List::new(self.items.clone())),
-            ),
+        div().elevation_2(cx).flex().flex_row().child(
+            v_stack()
+                .min_w(px(200.))
+                .track_focus(&self.focus_handle)
+                .on_mouse_down_out(|this: &mut Self, _, cx| this.cancel(&Default::default(), cx))
+                // .on_action(ContextMenu::select_first)
+                // .on_action(ContextMenu::select_last)
+                // .on_action(ContextMenu::select_next)
+                // .on_action(ContextMenu::select_prev)
+                .on_action(ContextMenu::confirm)
+                .on_action(ContextMenu::cancel)
+                .flex_none()
+                // .bg(cx.theme().colors().elevated_surface_background)
+                // .border()
+                // .border_color(cx.theme().colors().border)
+                .child(List::new(self.items.clone())),
         )
     }
 }
 
 pub struct MenuHandle<V: 'static> {
-    id: ElementId,
-    children: SmallVec<[AnyElement<V>; 2]>,
-    builder: Rc<dyn Fn(&mut V, &mut ViewContext<V>) -> View<ContextMenu> + 'static>,
-}
+    id: Option<ElementId>,
+    child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement<V> + 'static>>,
+    menu_builder: Option<Rc<dyn Fn(&mut V, &mut ViewContext<V>) -> View<ContextMenu> + 'static>>,
 
-impl<V: 'static> ParentComponent<V> for MenuHandle<V> {
-    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement<V>; 2]> {
-        &mut self.children
-    }
+    anchor: Option<AnchorCorner>,
+    attach: Option<AnchorCorner>,
 }
 
 impl<V: 'static> MenuHandle<V> {
-    pub fn new(
-        id: impl Into<ElementId>,
-        builder: impl Fn(&mut V, &mut ViewContext<V>) -> View<ContextMenu> + 'static,
+    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
+        self.id = Some(id.into());
+        self
+    }
+
+    pub fn menu(
+        mut self,
+        f: impl Fn(&mut V, &mut ViewContext<V>) -> View<ContextMenu> + 'static,
     ) -> Self {
-        Self {
-            id: id.into(),
-            children: SmallVec::new(),
-            builder: Rc::new(builder),
-        }
+        self.menu_builder = Some(Rc::new(f));
+        self
+    }
+
+    pub fn child<R: Component<V>>(mut self, f: impl FnOnce(bool) -> R + 'static) -> Self {
+        self.child_builder = Some(Box::new(|b| f(b).render()));
+        self
+    }
+
+    /// anchor defines which corner of the menu to anchor to the attachment point
+    /// (by default the cursor position, but see attach)
+    pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
+        self.anchor = Some(anchor);
+        self
+    }
+
+    /// attach defines which corner of the handle to attach the menu's anchor to
+    pub fn attach(mut self, attach: AnchorCorner) -> Self {
+        self.attach = Some(attach);
+        self
+    }
+}
+
+pub fn menu_handle<V: 'static>() -> MenuHandle<V> {
+    MenuHandle {
+        id: None,
+        child_builder: None,
+        menu_builder: None,
+        anchor: None,
+        attach: None,
     }
 }
 
 pub struct MenuHandleState<V> {
     menu: Rc<RefCell<Option<View<ContextMenu>>>>,
+    position: Rc<RefCell<Point<Pixels>>>,
+    child_layout_id: Option<LayoutId>,
+    child_element: Option<AnyElement<V>>,
     menu_element: Option<AnyElement<V>>,
 }
 impl<V: 'static> Element<V> for MenuHandle<V> {
     type ElementState = MenuHandleState<V>;
 
     fn element_id(&self) -> Option<gpui::ElementId> {
-        Some(self.id.clone())
+        Some(self.id.clone().expect("menu_handle must have an id()"))
     }
 
     fn layout(
@@ -170,27 +154,50 @@ impl<V: 'static> Element<V> for MenuHandle<V> {
         element_state: Option<Self::ElementState>,
         cx: &mut crate::ViewContext<V>,
     ) -> (gpui::LayoutId, Self::ElementState) {
-        let mut child_layout_ids = self
-            .children
-            .iter_mut()
-            .map(|child| child.layout(view_state, cx))
-            .collect::<SmallVec<[LayoutId; 2]>>();
-
-        let menu = if let Some(element_state) = element_state {
-            element_state.menu
+        let (menu, position) = if let Some(element_state) = element_state {
+            (element_state.menu, element_state.position)
         } else {
-            Rc::new(RefCell::new(None))
+            (Rc::default(), Rc::default())
         };
 
+        let mut menu_layout_id = None;
+
         let menu_element = menu.borrow_mut().as_mut().map(|menu| {
-            let mut view = menu.clone().render();
-            child_layout_ids.push(view.layout(view_state, cx));
+            let mut overlay = overlay::<V>().snap_to_window();
+            if let Some(anchor) = self.anchor {
+                overlay = overlay.anchor(anchor);
+            }
+            overlay = overlay.position(*position.borrow());
+
+            let mut view = overlay.child(menu.clone()).render();
+            menu_layout_id = Some(view.layout(view_state, cx));
             view
         });
 
-        let layout_id = cx.request_layout(&gpui::Style::default(), child_layout_ids.into_iter());
-
-        (layout_id, MenuHandleState { menu, menu_element })
+        let mut child_element = self
+            .child_builder
+            .take()
+            .map(|child_builder| (child_builder)(menu.borrow().is_some()));
+
+        let child_layout_id = child_element
+            .as_mut()
+            .map(|child_element| child_element.layout(view_state, cx));
+
+        let layout_id = cx.request_layout(
+            &gpui::Style::default(),
+            menu_layout_id.into_iter().chain(child_layout_id),
+        );
+
+        (
+            layout_id,
+            MenuHandleState {
+                menu,
+                position,
+                child_element,
+                child_layout_id,
+                menu_element,
+            },
+        )
     }
 
     fn paint(
@@ -200,7 +207,7 @@ impl<V: 'static> Element<V> for MenuHandle<V> {
         element_state: &mut Self::ElementState,
         cx: &mut crate::ViewContext<V>,
     ) {
-        for child in &mut self.children {
+        if let Some(child) = element_state.child_element.as_mut() {
             child.paint(view_state, cx);
         }
 
@@ -209,8 +216,14 @@ impl<V: 'static> Element<V> for MenuHandle<V> {
             return;
         }
 
+        let Some(builder) = self.menu_builder.clone() else {
+            return;
+        };
         let menu = element_state.menu.clone();
-        let builder = self.builder.clone();
+        let position = element_state.position.clone();
+        let attach = self.attach.clone();
+        let child_layout_id = element_state.child_layout_id.clone();
+
         cx.on_mouse_event(move |view_state, event: &MouseDownEvent, phase, cx| {
             if phase == DispatchPhase::Bubble
                 && event.button == MouseButton::Right
@@ -229,6 +242,14 @@ impl<V: 'static> Element<V> for MenuHandle<V> {
                 })
                 .detach();
                 *menu.borrow_mut() = Some(new_menu);
+
+                *position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() {
+                    attach
+                        .unwrap()
+                        .corner(cx.layout_bounds(child_layout_id.unwrap()))
+                } else {
+                    cx.mouse_position()
+                };
                 cx.notify();
             }
         });
@@ -250,35 +271,101 @@ mod stories {
     use crate::story::Story;
     use gpui::{action, Div, Render, VisualContext};
 
+    #[action]
+    struct PrintCurrentDate {}
+
+    fn build_menu(cx: &mut WindowContext, header: impl Into<SharedString>) -> View<ContextMenu> {
+        cx.build_view(|cx| {
+            ContextMenu::new(cx).header(header).separator().entry(
+                Label::new("Print current time"),
+                PrintCurrentDate {}.boxed_clone(),
+            )
+        })
+    }
+
     pub struct ContextMenuStory;
 
     impl Render for ContextMenuStory {
         type Element = Div<Self>;
 
         fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
-            #[action]
-            struct PrintCurrentDate {}
-
             Story::container(cx)
-                .child(Story::title_for::<_, ContextMenu>(cx))
                 .on_action(|_, _: &PrintCurrentDate, _| {
                     if let Ok(unix_time) = std::time::UNIX_EPOCH.elapsed() {
                         println!("Current Unix time is {:?}", unix_time.as_secs());
                     }
                 })
+                .flex()
+                .flex_row()
+                .justify_between()
+                .child(
+                    div()
+                        .flex()
+                        .flex_col()
+                        .justify_between()
+                        .child(
+                            menu_handle()
+                                .id("test2")
+                                .child(|is_open| {
+                                    Label::new(if is_open {
+                                        "TOP LEFT"
+                                    } else {
+                                        "RIGHT CLICK ME"
+                                    })
+                                    .render()
+                                })
+                                .menu(move |_, cx| build_menu(cx, "top left")),
+                        )
+                        .child(
+                            menu_handle()
+                                .id("test1")
+                                .child(|is_open| {
+                                    Label::new(if is_open {
+                                        "BOTTOM LEFT"
+                                    } else {
+                                        "RIGHT CLICK ME"
+                                    })
+                                    .render()
+                                })
+                                .anchor(AnchorCorner::BottomLeft)
+                                .attach(AnchorCorner::TopLeft)
+                                .menu(move |_, cx| build_menu(cx, "bottom left")),
+                        ),
+                )
                 .child(
-                    MenuHandle::new("test", move |_, cx| {
-                        cx.build_view(|cx| {
-                            ContextMenu::new(cx)
-                                .header("Section header")
-                                .separator()
-                                .entry(
-                                    Label::new("Print current time"),
-                                    PrintCurrentDate {}.boxed_clone(),
-                                )
-                        })
-                    })
-                    .child(Label::new("RIGHT CLICK ME")),
+                    div()
+                        .flex()
+                        .flex_col()
+                        .justify_between()
+                        .child(
+                            menu_handle()
+                                .id("test3")
+                                .child(|is_open| {
+                                    Label::new(if is_open {
+                                        "TOP RIGHT"
+                                    } else {
+                                        "RIGHT CLICK ME"
+                                    })
+                                    .render()
+                                })
+                                .anchor(AnchorCorner::TopRight)
+                                .menu(move |_, cx| build_menu(cx, "top right")),
+                        )
+                        .child(
+                            menu_handle()
+                                .id("test4")
+                                .child(|is_open| {
+                                    Label::new(if is_open {
+                                        "BOTTOM RIGHT"
+                                    } else {
+                                        "RIGHT CLICK ME"
+                                    })
+                                    .render()
+                                })
+                                .anchor(AnchorCorner::BottomRight)
+                                .attach(AnchorCorner::TopRight)
+                                .menu(move |_, cx| build_menu(cx, "bottom right")),
+                        ),
                 )
         }
     }

crates/ui2/src/components/icon_button.rs 🔗

@@ -19,6 +19,7 @@ pub struct IconButton<V: 'static> {
     color: TextColor,
     variant: ButtonVariant,
     state: InteractionState,
+    selected: bool,
     tooltip: Option<Box<dyn Fn(&mut V, &mut ViewContext<V>) -> AnyView + 'static>>,
     handlers: IconButtonHandlers<V>,
 }
@@ -31,6 +32,7 @@ impl<V: 'static> IconButton<V> {
             color: TextColor::default(),
             variant: ButtonVariant::default(),
             state: InteractionState::default(),
+            selected: false,
             tooltip: None,
             handlers: IconButtonHandlers::default(),
         }
@@ -56,6 +58,11 @@ impl<V: 'static> IconButton<V> {
         self
     }
 
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+
     pub fn tooltip(
         mut self,
         tooltip: impl Fn(&mut V, &mut ViewContext<V>) -> AnyView + 'static,
@@ -80,7 +87,7 @@ impl<V: 'static> IconButton<V> {
             _ => self.color,
         };
 
-        let (bg_color, bg_hover_color, bg_active_color) = match self.variant {
+        let (mut bg_color, bg_hover_color, bg_active_color) = match self.variant {
             ButtonVariant::Filled => (
                 cx.theme().colors().element_background,
                 cx.theme().colors().element_hover,
@@ -93,6 +100,10 @@ impl<V: 'static> IconButton<V> {
             ),
         };
 
+        if self.selected {
+            bg_color = bg_hover_color;
+        }
+
         let mut button = h_stack()
             .id(self.id.clone())
             .justify_center()
@@ -113,7 +124,9 @@ impl<V: 'static> IconButton<V> {
         }
 
         if let Some(tooltip) = self.tooltip.take() {
-            button = button.tooltip(move |view: &mut V, cx| (tooltip)(view, cx))
+            if !self.selected {
+                button = button.tooltip(move |view: &mut V, cx| (tooltip)(view, cx))
+            }
         }
 
         button

crates/workspace2/src/dock.rs 🔗

@@ -1,18 +1,18 @@
 use crate::{status_bar::StatusItemView, Axis, Workspace};
 use gpui::{
-    div, overlay, point, px, Action, AnyElement, AnyView, AppContext, Component, DispatchPhase,
-    Div, Element, ElementId, Entity, EntityId, EventEmitter, FocusHandle, FocusableView,
-    InteractiveComponent, LayoutId, MouseButton, MouseDownEvent, ParentComponent, Pixels, Point,
-    Render, SharedString, Style, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
-    WindowContext,
+    div, overlay, point, px, Action, AnchorCorner, AnyElement, AnyView, AppContext, Component,
+    DispatchPhase, Div, Element, ElementId, Entity, EntityId, EventEmitter, FocusHandle,
+    FocusableView, InteractiveComponent, LayoutId, MouseButton, MouseDownEvent, ParentComponent,
+    Pixels, Point, Render, SharedString, Style, Styled, Subscription, View, ViewContext,
+    VisualContext, WeakView, WindowContext,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
 use std::{cell::RefCell, rc::Rc, sync::Arc};
 use ui::{
-    h_stack, ContextMenu, ContextMenuItem, IconButton, InteractionState, Label, MenuEvent,
-    MenuHandle, Tooltip,
+    h_stack, menu_handle, ContextMenu, IconButton, InteractionState, Label, MenuEvent, MenuHandle,
+    Tooltip,
 };
 
 pub enum PanelEvent {
@@ -672,6 +672,13 @@ impl Render for PanelButtons {
         let active_index = dock.active_panel_index;
         let is_open = dock.is_open;
 
+        let (menu_anchor, menu_attach) = match dock.position {
+            DockPosition::Left => (AnchorCorner::BottomLeft, AnchorCorner::TopLeft),
+            DockPosition::Bottom | DockPosition::Right => {
+                (AnchorCorner::BottomRight, AnchorCorner::TopRight)
+            }
+        };
+
         let buttons = dock
             .panel_entries
             .iter()
@@ -697,11 +704,14 @@ impl Render for PanelButtons {
                 };
 
                 Some(
-                    MenuHandle::new(
-                        SharedString::from(format!("{} tooltip", name)),
-                        move |_, cx| cx.build_view(|cx| ContextMenu::new(cx).header("SECTION")),
-                    )
-                    .child(button),
+                    menu_handle()
+                        .id(name)
+                        .menu(move |_, cx| {
+                            cx.build_view(|cx| ContextMenu::new(cx).header("SECTION"))
+                        })
+                        .anchor(menu_anchor)
+                        .attach(menu_attach)
+                        .child(|is_open| button.selected(is_open)),
                 )
             });