Merge branch 'main' into zed2-breadcrumbs

Julia created

Change summary

crates/collab_ui2/src/collab_titlebar_item.rs     | 341 +++++++++++-----
crates/ui2/src/components.rs                      |   4 
crates/ui2/src/components/button/button_like.rs   |  73 +++
crates/ui2/src/components/context_menu.rs         | 178 --------
crates/ui2/src/components/popover_menu.rs         | 231 +++++++++++
crates/ui2/src/components/right_click_menu.rs     | 185 +++++++++
crates/ui2/src/components/stories/context_menu.rs |  42 -
crates/workspace2/src/dock.rs                     |  12 
crates/workspace2/src/pane.rs                     |   4 
crates/workspace2/src/workspace2.rs               |   2 
10 files changed, 746 insertions(+), 326 deletions(-)

Detailed changes

crates/collab_ui2/src/collab_titlebar_item.rs 🔗

@@ -35,16 +35,19 @@ use gpui::{
     ParentElement, Render, RenderOnce, Stateful, StatefulInteractiveElement, Styled, Subscription,
     ViewContext, VisualContext, WeakView, WindowBounds,
 };
-use project::Project;
+use project::{Project, RepositoryEntry};
 use theme::ActiveTheme;
-use ui::{h_stack, prelude::*, Avatar, Button, ButtonStyle, IconButton, KeyBinding, Tooltip};
+use ui::{
+    h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
+    IconButton, IconElement, KeyBinding, Tooltip,
+};
 use util::ResultExt;
 use workspace::{notifications::NotifyResultExt, Workspace};
 
 use crate::face_pile::FacePile;
 
-// const MAX_PROJECT_NAME_LENGTH: usize = 40;
-// const MAX_BRANCH_NAME_LENGTH: usize = 40;
+const MAX_PROJECT_NAME_LENGTH: usize = 40;
+const MAX_BRANCH_NAME_LENGTH: usize = 40;
 
 // actions!(
 //     collab,
@@ -100,17 +103,18 @@ impl Render for CollabTitlebarItem {
             .update(cx, |this, cx| this.call_state().remote_participants(cx))
             .log_err()
             .flatten();
-        let mic_icon = if self
+        let is_muted = self
             .workspace
             .update(cx, |this, cx| this.call_state().is_muted(cx))
             .log_err()
             .flatten()
-            .unwrap_or_default()
-        {
-            ui::Icon::MicMute
-        } else {
-            ui::Icon::Mic
-        };
+            .unwrap_or_default();
+        let is_deafened = self
+            .workspace
+            .update(cx, |this, cx| this.call_state().is_deafened(cx))
+            .log_err()
+            .flatten()
+            .unwrap_or_default();
         let speakers_icon = if self
             .workspace
             .update(cx, |this, cx| this.call_state().is_deafened(cx))
@@ -146,56 +150,11 @@ impl Render for CollabTitlebarItem {
             .child(
                 h_stack()
                     .gap_1()
-                    // TODO - Add player menu
-                    .child(
-                        div()
-                            .border()
-                            .border_color(gpui::red())
-                            .id("project_owner_indicator")
-                            .child(
-                                Button::new("player", "player")
-                                    .style(ButtonStyle::Subtle)
-                                    .color(Some(Color::Player(0))),
-                            )
-                            .tooltip(move |cx| Tooltip::text("Toggle following", cx)),
-                    )
-                    // TODO - Add project menu
-                    .child(
-                        div()
-                            .border()
-                            .border_color(gpui::red())
-                            .id("titlebar_project_menu_button")
-                            .child(
-                                Button::new("project_name", "project_name")
-                                    .style(ButtonStyle::Subtle),
-                            )
-                            .tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
-                    )
-                    // TODO - Add git menu
-                    .child(
-                        div()
-                            .border()
-                            .border_color(gpui::red())
-                            .id("titlebar_git_menu_button")
-                            .child(
-                                Button::new("branch_name", "branch_name")
-                                    .style(ButtonStyle::Subtle)
-                                    .color(Some(Color::Muted)),
-                            )
-                            .tooltip(move |cx| {
-                                cx.build_view(|_| {
-                                    Tooltip::new("Recent Branches")
-                                        .key_binding(KeyBinding::new(gpui::KeyBinding::new(
-                                            "cmd-b",
-                                            // todo!() Replace with real action.
-                                            gpui::NoAction,
-                                            None,
-                                        )))
-                                        .meta("Only local branches shown")
-                                })
-                                .into()
-                            }),
-                    ),
+                    .when(is_in_room, |this| {
+                        this.children(self.render_project_owner(cx))
+                    })
+                    .child(self.render_project_name(cx))
+                    .children(self.render_project_branch(cx)),
             )
             .when_some(
                 users.zip(current_user.clone()),
@@ -236,62 +195,126 @@ impl Render for CollabTitlebarItem {
             .when(is_in_room, |this| {
                 this.child(
                     h_stack()
+                        .gap_1()
                         .child(
                             h_stack()
-                                .child(Button::new(
-                                    "toggle_sharing",
-                                    if is_shared { "Unshare" } else { "Share" },
-                                ))
-                                .child(IconButton::new("leave-call", ui::Icon::Exit).on_click({
-                                    let workspace = workspace.clone();
-                                    move |_, cx| {
-                                        workspace
-                                            .update(cx, |this, cx| {
-                                                this.call_state().hang_up(cx).detach();
-                                            })
-                                            .log_err();
-                                    }
-                                })),
+                                .gap_1()
+                                .child(
+                                    Button::new(
+                                        "toggle_sharing",
+                                        if is_shared { "Unshare" } else { "Share" },
+                                    )
+                                    .style(ButtonStyle::Subtle),
+                                )
+                                .child(
+                                    IconButton::new("leave-call", ui::Icon::Exit)
+                                        .style(ButtonStyle::Subtle)
+                                        .on_click({
+                                            let workspace = workspace.clone();
+                                            move |_, cx| {
+                                                workspace
+                                                    .update(cx, |this, cx| {
+                                                        this.call_state().hang_up(cx).detach();
+                                                    })
+                                                    .log_err();
+                                            }
+                                        }),
+                                ),
                         )
                         .child(
                             h_stack()
-                                .child(IconButton::new("mute-microphone", mic_icon).on_click({
-                                    let workspace = workspace.clone();
-                                    move |_, cx| {
-                                        workspace
-                                            .update(cx, |this, cx| {
-                                                this.call_state().toggle_mute(cx);
-                                            })
-                                            .log_err();
-                                    }
-                                }))
-                                .child(IconButton::new("mute-sound", speakers_icon).on_click({
-                                    let workspace = workspace.clone();
-                                    move |_, cx| {
-                                        workspace
-                                            .update(cx, |this, cx| {
-                                                this.call_state().toggle_deafen(cx);
-                                            })
-                                            .log_err();
-                                    }
-                                }))
-                                .child(IconButton::new("screen-share", ui::Icon::Screen).on_click(
-                                    move |_, cx| {
-                                        workspace
-                                            .update(cx, |this, cx| {
-                                                this.call_state().toggle_screen_share(cx);
-                                            })
-                                            .log_err();
-                                    },
-                                ))
+                                .gap_1()
+                                .child(
+                                    IconButton::new(
+                                        "mute-microphone",
+                                        if is_muted {
+                                            ui::Icon::MicMute
+                                        } else {
+                                            ui::Icon::Mic
+                                        },
+                                    )
+                                    .style(ButtonStyle::Subtle)
+                                    .selected(is_muted)
+                                    .on_click({
+                                        let workspace = workspace.clone();
+                                        move |_, cx| {
+                                            workspace
+                                                .update(cx, |this, cx| {
+                                                    this.call_state().toggle_mute(cx);
+                                                })
+                                                .log_err();
+                                        }
+                                    }),
+                                )
+                                .child(
+                                    IconButton::new("mute-sound", speakers_icon)
+                                        .style(ButtonStyle::Subtle)
+                                        .selected(is_deafened.clone())
+                                        .tooltip(move |cx| {
+                                            Tooltip::with_meta(
+                                                "Deafen Audio",
+                                                None,
+                                                "Mic will be muted",
+                                                cx,
+                                            )
+                                        })
+                                        .on_click({
+                                            let workspace = workspace.clone();
+                                            move |_, cx| {
+                                                workspace
+                                                    .update(cx, |this, cx| {
+                                                        this.call_state().toggle_deafen(cx);
+                                                    })
+                                                    .log_err();
+                                            }
+                                        }),
+                                )
+                                .child(
+                                    IconButton::new("screen-share", ui::Icon::Screen)
+                                        .style(ButtonStyle::Subtle)
+                                        .on_click(move |_, cx| {
+                                            workspace
+                                                .update(cx, |this, cx| {
+                                                    this.call_state().toggle_screen_share(cx);
+                                                })
+                                                .log_err();
+                                        }),
+                                )
                                 .pl_2(),
                         ),
                 )
             })
-            .map(|this| {
+            .child(h_stack().px_1p5().map(|this| {
                 if let Some(user) = current_user {
                     this.when_some(user.avatar.clone(), |this, avatar| {
-                        this.child(ui::Avatar::data(avatar))
+                        // TODO: Finish implementing user menu popover
+                        //
+                        this.child(
+                            popover_menu("user-menu")
+                                .menu(|cx| ContextMenu::build(cx, |menu, _| menu.header("ADADA")))
+                                .trigger(
+                                    ButtonLike::new("user-menu")
+                                        .child(
+                                            h_stack().gap_0p5().child(Avatar::data(avatar)).child(
+                                                IconElement::new(Icon::ChevronDown)
+                                                    .color(Color::Muted),
+                                            ),
+                                        )
+                                        .style(ButtonStyle::Subtle)
+                                        .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
+                                )
+                                .anchor(gpui::AnchorCorner::TopRight),
+                        )
+                        // this.child(
+                        //     ButtonLike::new("user-menu")
+                        //         .child(
+                        //             h_stack().gap_0p5().child(Avatar::data(avatar)).child(
+                        //                 IconElement::new(Icon::ChevronDown).color(Color::Muted),
+                        //             ),
+                        //         )
+                        //         .style(ButtonStyle::Subtle)
+                        //         .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
+                        // )
                     })
                 } else {
                     this.child(Button::new("sign_in", "Sign in").on_click(move |_, cx| {
@@ -305,7 +328,7 @@ impl Render for CollabTitlebarItem {
                         .detach();
                     }))
                 }
-            })
+            }))
     }
 }
 
@@ -424,6 +447,110 @@ impl CollabTitlebarItem {
         }
     }
 
+    // resolve if you are in a room -> render_project_owner
+    // render_project_owner -> resolve if you are in a room -> Option<foo>
+
+    pub fn render_project_owner(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
+        // TODO: We can't finish implementing this until project sharing works
+        // - [ ] Show the project owner when the project is remote (maybe done)
+        // - [x] Show the project owner when the project is local
+        // - [ ] Show the project owner with a lock icon when the project is local and unshared
+
+        let remote_id = self.project.read(cx).remote_id();
+        let is_local = remote_id.is_none();
+        let is_shared = self.project.read(cx).is_shared();
+        let (user_name, participant_index) = {
+            if let Some(host) = self.project.read(cx).host() {
+                debug_assert!(!is_local);
+                let (Some(host_user), Some(participant_index)) = (
+                    self.user_store.read(cx).get_cached_user(host.user_id),
+                    self.user_store
+                        .read(cx)
+                        .participant_indices()
+                        .get(&host.user_id),
+                ) else {
+                    return None;
+                };
+                (host_user.github_login.clone(), participant_index.0)
+            } else {
+                debug_assert!(is_local);
+                let name = self
+                    .user_store
+                    .read(cx)
+                    .current_user()
+                    .map(|user| user.github_login.clone())?;
+                (name, 0)
+            }
+        };
+        Some(
+            div().border().border_color(gpui::red()).child(
+                Button::new(
+                    "project_owner_trigger",
+                    format!("{user_name} ({})", !is_shared),
+                )
+                .color(Color::Player(participant_index))
+                .style(ButtonStyle::Subtle)
+                .tooltip(move |cx| Tooltip::text("Toggle following", cx)),
+            ),
+        )
+    }
+
+    pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> impl Element {
+        let name = {
+            let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| {
+                let worktree = worktree.read(cx);
+                worktree.root_name()
+            });
+
+            names.next().unwrap_or("")
+        };
+
+        let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
+
+        div().border().border_color(gpui::red()).child(
+            Button::new("project_name_trigger", name)
+                .style(ButtonStyle::Subtle)
+                .tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
+        )
+    }
+
+    pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
+        let entry = {
+            let mut names_and_branches =
+                self.project.read(cx).visible_worktrees(cx).map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    worktree.root_git_entry()
+                });
+
+            names_and_branches.next().flatten()
+        };
+
+        let branch_name = entry
+            .as_ref()
+            .and_then(RepositoryEntry::branch)
+            .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
+
+        Some(
+            div().border().border_color(gpui::red()).child(
+                Button::new("project_branch_trigger", branch_name)
+                    .style(ButtonStyle::Subtle)
+                    .tooltip(move |cx| {
+                        cx.build_view(|_| {
+                            Tooltip::new("Recent Branches")
+                                .key_binding(KeyBinding::new(gpui::KeyBinding::new(
+                                    "cmd-b",
+                                    // todo!() Replace with real action.
+                                    gpui::NoAction,
+                                    None,
+                                )))
+                                .meta("Local branches only")
+                        })
+                        .into()
+                    }),
+            ),
+        )
+    }
+
     // fn collect_title_root_names(
     //     &self,
     //     theme: Arc<Theme>,

crates/ui2/src/components.rs 🔗

@@ -9,6 +9,8 @@ mod keybinding;
 mod label;
 mod list;
 mod popover;
+mod popover_menu;
+mod right_click_menu;
 mod stack;
 mod tooltip;
 
@@ -26,6 +28,8 @@ pub use keybinding::*;
 pub use label::*;
 pub use list::*;
 pub use popover::*;
+pub use popover_menu::*;
+pub use right_click_menu::*;
 pub use stack::*;
 pub use tooltip::*;
 

crates/ui2/src/components/button/button_like.rs 🔗

@@ -5,18 +5,44 @@ use crate::h_stack;
 use crate::prelude::*;
 
 pub trait ButtonCommon: Clickable + Disableable {
+    /// A unique element id to help identify the button.
     fn id(&self) -> &ElementId;
+    /// The visual style of the button.
+    ///
+    /// Mosty commonly will be `ButtonStyle::Subtle`, or `ButtonStyle::Filled`
+    /// for an emphasized button.
     fn style(self, style: ButtonStyle) -> Self;
+    /// The size of the button.
+    ///
+    /// Most buttons will use the default size.
+    ///
+    /// ButtonSize can also be used to help build  non-button elements
+    /// that are consistently sized with buttons.
     fn size(self, size: ButtonSize) -> Self;
+    /// The tooltip that shows when a user hovers over the button.
+    ///
+    /// Nearly all interactable elements should have a tooltip. Some example
+    /// exceptions might a scroll bar, or a slider.
     fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self;
 }
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 pub enum ButtonStyle {
-    #[default]
+    /// A filled button with a solid background color. Provides emphasis versus
+    /// the more common subtle button.
     Filled,
-    // Tinted,
+    /// 🚧 Under construction 🚧
+    ///
+    /// Used to emphasize a button in some way, like a selected state, or a semantic
+    /// coloring like an error or success button.
+    Tinted,
+    /// The default button style, used for most buttons. Has a transparent background,
+    /// but has a background color to indicate states like hover and active.
+    #[default]
     Subtle,
+    /// Used for buttons that only change forground color on hover and active states.
+    ///
+    /// TODO: Better docs for this.
     Transparent,
 }
 
@@ -40,6 +66,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::Tinted => ButtonLikeStyles {
+                background: gpui::red(),
+                border_color: gpui::red(),
+                label_color: gpui::red(),
+                icon_color: gpui::red(),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_background,
                 border_color: transparent_black(),
@@ -63,6 +95,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::Tinted => ButtonLikeStyles {
+                background: gpui::red(),
+                border_color: gpui::red(),
+                label_color: gpui::red(),
+                icon_color: gpui::red(),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_hover,
                 border_color: transparent_black(),
@@ -88,6 +126,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::Tinted => ButtonLikeStyles {
+                background: gpui::red(),
+                border_color: gpui::red(),
+                label_color: gpui::red(),
+                icon_color: gpui::red(),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_active,
                 border_color: transparent_black(),
@@ -114,6 +158,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::Tinted => ButtonLikeStyles {
+                background: gpui::red(),
+                border_color: gpui::red(),
+                label_color: gpui::red(),
+                icon_color: gpui::red(),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_background,
                 border_color: cx.theme().colors().border_focused,
@@ -137,6 +187,12 @@ impl ButtonStyle {
                 label_color: Color::Disabled.color(cx),
                 icon_color: Color::Disabled.color(cx),
             },
+            ButtonStyle::Tinted => ButtonLikeStyles {
+                background: gpui::red(),
+                border_color: gpui::red(),
+                label_color: gpui::red(),
+                icon_color: gpui::red(),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_disabled,
                 border_color: cx.theme().colors().border_disabled,
@@ -153,6 +209,8 @@ impl ButtonStyle {
     }
 }
 
+/// ButtonSize can also be used to help build  non-button elements
+/// that are consistently sized with buttons.
 #[derive(Default, PartialEq, Clone, Copy)]
 pub enum ButtonSize {
     #[default]
@@ -171,6 +229,11 @@ impl ButtonSize {
     }
 }
 
+/// A button-like element that can be used to create a custom button when
+/// prebuilt buttons are not sufficient. Use this sparingly, as it is
+/// unconstrained and may make the UI feel less consistent.
+///
+/// This is also used to build the prebuilt buttons.
 #[derive(IntoElement)]
 pub struct ButtonLike {
     id: ElementId,
@@ -270,7 +333,11 @@ impl RenderOnce for ButtonLike {
                 },
             )
             .when_some(self.tooltip, |this, tooltip| {
-                this.tooltip(move |cx| tooltip(cx))
+                if !self.selected {
+                    this.tooltip(move |cx| tooltip(cx))
+                } else {
+                    this
+                }
             })
             .children(self.children)
     }

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

@@ -2,12 +2,11 @@ use crate::{
     h_stack, prelude::*, v_stack, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader,
 };
 use gpui::{
-    overlay, px, Action, AnchorCorner, AnyElement, AppContext, Bounds, DismissEvent, DispatchPhase,
-    Div, EventEmitter, FocusHandle, FocusableView, IntoElement, LayoutId, ManagedView, MouseButton,
-    MouseDownEvent, Pixels, Point, Render, View, VisualContext,
+    px, Action, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView,
+    IntoElement, Render, View, VisualContext,
 };
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
-use std::{cell::RefCell, rc::Rc};
+use std::rc::Rc;
 
 pub enum ContextMenuItem {
     Separator,
@@ -208,174 +207,3 @@ impl Render for ContextMenu {
         )
     }
 }
-
-pub struct MenuHandle<M: ManagedView> {
-    id: ElementId,
-    child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement + 'static>>,
-    menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
-    anchor: Option<AnchorCorner>,
-    attach: Option<AnchorCorner>,
-}
-
-impl<M: ManagedView> MenuHandle<M> {
-    pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View<M> + 'static) -> Self {
-        self.menu_builder = Some(Rc::new(f));
-        self
-    }
-
-    pub fn child<R: IntoElement>(mut self, f: impl FnOnce(bool) -> R + 'static) -> Self {
-        self.child_builder = Some(Box::new(|b| f(b).into_element().into_any()));
-        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<M: ManagedView>(id: impl Into<ElementId>) -> MenuHandle<M> {
-    MenuHandle {
-        id: id.into(),
-        child_builder: None,
-        menu_builder: None,
-        anchor: None,
-        attach: None,
-    }
-}
-
-pub struct MenuHandleState<M> {
-    menu: Rc<RefCell<Option<View<M>>>>,
-    position: Rc<RefCell<Point<Pixels>>>,
-    child_layout_id: Option<LayoutId>,
-    child_element: Option<AnyElement>,
-    menu_element: Option<AnyElement>,
-}
-
-impl<M: ManagedView> Element for MenuHandle<M> {
-    type State = MenuHandleState<M>;
-
-    fn layout(
-        &mut self,
-        element_state: Option<Self::State>,
-        cx: &mut WindowContext,
-    ) -> (gpui::LayoutId, Self::State) {
-        let (menu, position) = if let Some(element_state) = element_state {
-            (element_state.menu, element_state.position)
-        } else {
-            (Rc::default(), Rc::default())
-        };
-
-        let mut menu_layout_id = None;
-
-        let menu_element = menu.borrow_mut().as_mut().map(|menu| {
-            let mut overlay = overlay().snap_to_window();
-            if let Some(anchor) = self.anchor {
-                overlay = overlay.anchor(anchor);
-            }
-            overlay = overlay.position(*position.borrow());
-
-            let mut element = overlay.child(menu.clone()).into_any();
-            menu_layout_id = Some(element.layout(cx));
-            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(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(
-        self,
-        bounds: Bounds<gpui::Pixels>,
-        element_state: &mut Self::State,
-        cx: &mut WindowContext,
-    ) {
-        if let Some(child) = element_state.child_element.take() {
-            child.paint(cx);
-        }
-
-        if let Some(menu) = element_state.menu_element.take() {
-            menu.paint(cx);
-            return;
-        }
-
-        let Some(builder) = self.menu_builder else {
-            return;
-        };
-        let menu = element_state.menu.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 |event: &MouseDownEvent, phase, cx| {
-            if phase == DispatchPhase::Bubble
-                && event.button == MouseButton::Right
-                && bounds.contains_point(&event.position)
-            {
-                cx.stop_propagation();
-                cx.prevent_default();
-
-                let new_menu = (builder)(cx);
-                let menu2 = menu.clone();
-                cx.subscribe(&new_menu, move |_modal, _: &DismissEvent, cx| {
-                    *menu2.borrow_mut() = None;
-                    cx.notify();
-                })
-                .detach();
-                cx.focus_view(&new_menu);
-                *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();
-            }
-        });
-    }
-}
-
-impl<M: ManagedView> IntoElement for MenuHandle<M> {
-    type Element = Self;
-
-    fn element_id(&self) -> Option<gpui::ElementId> {
-        Some(self.id.clone())
-    }
-
-    fn into_element(self) -> Self::Element {
-        self
-    }
-}

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

@@ -0,0 +1,231 @@
+use std::{cell::RefCell, rc::Rc};
+
+use gpui::{
+    overlay, point, px, rems, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase,
+    Element, ElementId, InteractiveBounds, IntoElement, LayoutId, ManagedView, MouseDownEvent,
+    ParentElement, Pixels, Point, View, VisualContext, WindowContext,
+};
+
+use crate::{Clickable, Selectable};
+
+pub trait PopoverTrigger: IntoElement + Clickable + Selectable + 'static {}
+
+impl<T: IntoElement + Clickable + Selectable + 'static> PopoverTrigger for T {}
+
+pub struct PopoverMenu<M: ManagedView> {
+    id: ElementId,
+    child_builder: Option<
+        Box<
+            dyn FnOnce(
+                    Rc<RefCell<Option<View<M>>>>,
+                    Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
+                ) -> AnyElement
+                + 'static,
+        >,
+    >,
+    menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
+    anchor: AnchorCorner,
+    attach: Option<AnchorCorner>,
+    offset: Option<Point<Pixels>>,
+}
+
+impl<M: ManagedView> PopoverMenu<M> {
+    pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View<M> + 'static) -> Self {
+        self.menu_builder = Some(Rc::new(f));
+        self
+    }
+
+    pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
+        self.child_builder = Some(Box::new(|menu, builder| {
+            let open = menu.borrow().is_some();
+            t.selected(open)
+                .when_some(builder, |el, builder| {
+                    el.on_click({
+                        move |_, cx| {
+                            let new_menu = (builder)(cx);
+                            let menu2 = menu.clone();
+                            let previous_focus_handle = cx.focused();
+
+                            cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| {
+                                if modal.focus_handle(cx).contains_focused(cx) {
+                                    if previous_focus_handle.is_some() {
+                                        cx.focus(&previous_focus_handle.as_ref().unwrap())
+                                    }
+                                }
+                                *menu2.borrow_mut() = None;
+                                cx.notify();
+                            })
+                            .detach();
+                            cx.focus_view(&new_menu);
+                            *menu.borrow_mut() = Some(new_menu);
+                        }
+                    })
+                })
+                .into_any_element()
+        }));
+        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 = 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
+    }
+
+    /// offset offsets the position of the content by that many pixels.
+    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
+        self.offset = Some(offset);
+        self
+    }
+
+    fn resolved_attach(&self) -> AnchorCorner {
+        self.attach.unwrap_or_else(|| match self.anchor {
+            AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
+            AnchorCorner::TopRight => AnchorCorner::BottomRight,
+            AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
+            AnchorCorner::BottomRight => AnchorCorner::TopRight,
+        })
+    }
+
+    fn resolved_offset(&self, cx: &WindowContext) -> Point<Pixels> {
+        self.offset.unwrap_or_else(|| {
+            // Default offset = 4px padding + 1px border
+            let offset = rems(5. / 16.) * cx.rem_size();
+            match self.anchor {
+                AnchorCorner::TopRight | AnchorCorner::BottomRight => point(offset, px(0.)),
+                AnchorCorner::TopLeft | AnchorCorner::BottomLeft => point(-offset, px(0.)),
+            }
+        })
+    }
+}
+
+pub fn popover_menu<M: ManagedView>(id: impl Into<ElementId>) -> PopoverMenu<M> {
+    PopoverMenu {
+        id: id.into(),
+        child_builder: None,
+        menu_builder: None,
+        anchor: AnchorCorner::TopLeft,
+        attach: None,
+        offset: None,
+    }
+}
+
+pub struct PopoverMenuState<M> {
+    child_layout_id: Option<LayoutId>,
+    child_element: Option<AnyElement>,
+    child_bounds: Option<Bounds<Pixels>>,
+    menu_element: Option<AnyElement>,
+    menu: Rc<RefCell<Option<View<M>>>>,
+}
+
+impl<M: ManagedView> Element for PopoverMenu<M> {
+    type State = PopoverMenuState<M>;
+
+    fn layout(
+        &mut self,
+        element_state: Option<Self::State>,
+        cx: &mut WindowContext,
+    ) -> (gpui::LayoutId, Self::State) {
+        let mut menu_layout_id = None;
+
+        let (menu, child_bounds) = if let Some(element_state) = element_state {
+            (element_state.menu, element_state.child_bounds)
+        } else {
+            (Rc::default(), None)
+        };
+
+        let menu_element = menu.borrow_mut().as_mut().map(|menu| {
+            let mut overlay = overlay().snap_to_window().anchor(self.anchor);
+
+            if let Some(child_bounds) = child_bounds {
+                overlay = overlay.position(
+                    self.resolved_attach().corner(child_bounds) + self.resolved_offset(cx),
+                );
+            }
+
+            let mut element = overlay.child(menu.clone()).into_any();
+            menu_layout_id = Some(element.layout(cx));
+            element
+        });
+
+        let mut child_element = self
+            .child_builder
+            .take()
+            .map(|child_builder| (child_builder)(menu.clone(), self.menu_builder.clone()));
+
+        let child_layout_id = child_element
+            .as_mut()
+            .map(|child_element| child_element.layout(cx));
+
+        let layout_id = cx.request_layout(
+            &gpui::Style::default(),
+            menu_layout_id.into_iter().chain(child_layout_id),
+        );
+
+        (
+            layout_id,
+            PopoverMenuState {
+                menu,
+                child_element,
+                child_layout_id,
+                menu_element,
+                child_bounds,
+            },
+        )
+    }
+
+    fn paint(
+        self,
+        _: Bounds<gpui::Pixels>,
+        element_state: &mut Self::State,
+        cx: &mut WindowContext,
+    ) {
+        if let Some(child) = element_state.child_element.take() {
+            child.paint(cx);
+        }
+
+        if let Some(child_layout_id) = element_state.child_layout_id.take() {
+            element_state.child_bounds = Some(cx.layout_bounds(child_layout_id));
+        }
+
+        if let Some(menu) = element_state.menu_element.take() {
+            menu.paint(cx);
+
+            if let Some(child_bounds) = element_state.child_bounds {
+                let interactive_bounds = InteractiveBounds {
+                    bounds: child_bounds,
+                    stacking_order: cx.stacking_order().clone(),
+                };
+
+                // Mouse-downing outside the menu dismisses it, so we don't
+                // want a click on the toggle to re-open it.
+                cx.on_mouse_event(move |e: &MouseDownEvent, phase, cx| {
+                    if phase == DispatchPhase::Bubble
+                        && interactive_bounds.visibly_contains(&e.position, cx)
+                    {
+                        cx.stop_propagation()
+                    }
+                })
+            }
+        }
+    }
+}
+
+impl<M: ManagedView> IntoElement for PopoverMenu<M> {
+    type Element = Self;
+
+    fn element_id(&self) -> Option<gpui::ElementId> {
+        Some(self.id.clone())
+    }
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}

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

@@ -0,0 +1,185 @@
+use std::{cell::RefCell, rc::Rc};
+
+use gpui::{
+    overlay, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, Element, ElementId,
+    IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
+    View, VisualContext, WindowContext,
+};
+
+pub struct RightClickMenu<M: ManagedView> {
+    id: ElementId,
+    child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement + 'static>>,
+    menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
+    anchor: Option<AnchorCorner>,
+    attach: Option<AnchorCorner>,
+}
+
+impl<M: ManagedView> RightClickMenu<M> {
+    pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View<M> + 'static) -> Self {
+        self.menu_builder = Some(Rc::new(f));
+        self
+    }
+
+    pub fn trigger<E: IntoElement + 'static>(mut self, e: E) -> Self {
+        self.child_builder = Some(Box::new(move |_| e.into_any_element()));
+        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 right_click_menu<M: ManagedView>(id: impl Into<ElementId>) -> RightClickMenu<M> {
+    RightClickMenu {
+        id: id.into(),
+        child_builder: None,
+        menu_builder: None,
+        anchor: None,
+        attach: None,
+    }
+}
+
+pub struct MenuHandleState<M> {
+    menu: Rc<RefCell<Option<View<M>>>>,
+    position: Rc<RefCell<Point<Pixels>>>,
+    child_layout_id: Option<LayoutId>,
+    child_element: Option<AnyElement>,
+    menu_element: Option<AnyElement>,
+}
+
+impl<M: ManagedView> Element for RightClickMenu<M> {
+    type State = MenuHandleState<M>;
+
+    fn layout(
+        &mut self,
+        element_state: Option<Self::State>,
+        cx: &mut WindowContext,
+    ) -> (gpui::LayoutId, Self::State) {
+        let (menu, position) = if let Some(element_state) = element_state {
+            (element_state.menu, element_state.position)
+        } else {
+            (Rc::default(), Rc::default())
+        };
+
+        let mut menu_layout_id = None;
+
+        let menu_element = menu.borrow_mut().as_mut().map(|menu| {
+            let mut overlay = overlay().snap_to_window();
+            if let Some(anchor) = self.anchor {
+                overlay = overlay.anchor(anchor);
+            }
+            overlay = overlay.position(*position.borrow());
+
+            let mut element = overlay.child(menu.clone()).into_any();
+            menu_layout_id = Some(element.layout(cx));
+            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(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(
+        self,
+        bounds: Bounds<gpui::Pixels>,
+        element_state: &mut Self::State,
+        cx: &mut WindowContext,
+    ) {
+        if let Some(child) = element_state.child_element.take() {
+            child.paint(cx);
+        }
+
+        if let Some(menu) = element_state.menu_element.take() {
+            menu.paint(cx);
+            return;
+        }
+
+        let Some(builder) = self.menu_builder else {
+            return;
+        };
+        let menu = element_state.menu.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 |event: &MouseDownEvent, phase, cx| {
+            if phase == DispatchPhase::Bubble
+                && event.button == MouseButton::Right
+                && bounds.contains_point(&event.position)
+            {
+                cx.stop_propagation();
+                cx.prevent_default();
+
+                let new_menu = (builder)(cx);
+                let menu2 = menu.clone();
+                let previous_focus_handle = cx.focused();
+
+                cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| {
+                    if modal.focus_handle(cx).contains_focused(cx) {
+                        if previous_focus_handle.is_some() {
+                            cx.focus(&previous_focus_handle.as_ref().unwrap())
+                        }
+                    }
+                    *menu2.borrow_mut() = None;
+                    cx.notify();
+                })
+                .detach();
+                cx.focus_view(&new_menu);
+                *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();
+            }
+        });
+    }
+}
+
+impl<M: ManagedView> IntoElement for RightClickMenu<M> {
+    type Element = Self;
+
+    fn element_id(&self) -> Option<gpui::ElementId> {
+        Some(self.id.clone())
+    }
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}

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

@@ -2,7 +2,7 @@ use gpui::{actions, Action, AnchorCorner, Div, Render, View};
 use story::Story;
 
 use crate::prelude::*;
-use crate::{menu_handle, ContextMenu, Label};
+use crate::{right_click_menu, ContextMenu, Label};
 
 actions!(PrintCurrentDate, PrintBestFood);
 
@@ -45,25 +45,13 @@ impl Render for ContextMenuStory {
                     .flex_col()
                     .justify_between()
                     .child(
-                        menu_handle("test2")
-                            .child(|is_open| {
-                                Label::new(if is_open {
-                                    "TOP LEFT"
-                                } else {
-                                    "RIGHT CLICK ME"
-                                })
-                            })
+                        right_click_menu("test2")
+                            .trigger(Label::new("TOP LEFT"))
                             .menu(move |cx| build_menu(cx, "top left")),
                     )
                     .child(
-                        menu_handle("test1")
-                            .child(|is_open| {
-                                Label::new(if is_open {
-                                    "BOTTOM LEFT"
-                                } else {
-                                    "RIGHT CLICK ME"
-                                })
-                            })
+                        right_click_menu("test1")
+                            .trigger(Label::new("BOTTOM LEFT"))
                             .anchor(AnchorCorner::BottomLeft)
                             .attach(AnchorCorner::TopLeft)
                             .menu(move |cx| build_menu(cx, "bottom left")),
@@ -75,26 +63,14 @@ impl Render for ContextMenuStory {
                     .flex_col()
                     .justify_between()
                     .child(
-                        menu_handle("test3")
-                            .child(|is_open| {
-                                Label::new(if is_open {
-                                    "TOP RIGHT"
-                                } else {
-                                    "RIGHT CLICK ME"
-                                })
-                            })
+                        right_click_menu("test3")
+                            .trigger(Label::new("TOP RIGHT"))
                             .anchor(AnchorCorner::TopRight)
                             .menu(move |cx| build_menu(cx, "top right")),
                     )
                     .child(
-                        menu_handle("test4")
-                            .child(|is_open| {
-                                Label::new(if is_open {
-                                    "BOTTOM RIGHT"
-                                } else {
-                                    "RIGHT CLICK ME"
-                                })
-                            })
+                        right_click_menu("test4")
+                            .trigger(Label::new("BOTTOM RIGHT"))
                             .anchor(AnchorCorner::BottomRight)
                             .attach(AnchorCorner::TopRight)
                             .menu(move |cx| build_menu(cx, "bottom right")),

crates/workspace2/src/dock.rs 🔗

@@ -7,8 +7,8 @@ use gpui::{
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
-use ui::prelude::*;
-use ui::{h_stack, menu_handle, ContextMenu, IconButton, Tooltip};
+use ui::{h_stack, ContextMenu, IconButton, Tooltip};
+use ui::{prelude::*, right_click_menu};
 
 pub enum PanelEvent {
     ChangePosition,
@@ -702,7 +702,7 @@ impl Render for PanelButtons {
                 };
 
                 Some(
-                    menu_handle(name)
+                    right_click_menu(name)
                         .menu(move |cx| {
                             const POSITIONS: [DockPosition; 3] = [
                                 DockPosition::Left,
@@ -726,14 +726,14 @@ impl Render for PanelButtons {
                         })
                         .anchor(menu_anchor)
                         .attach(menu_attach)
-                        .child(move |_is_open| {
+                        .trigger(
                             IconButton::new(name, icon)
                                 .selected(is_active_button)
                                 .action(action.boxed_clone())
                                 .tooltip(move |cx| {
                                     Tooltip::for_action(tooltip.clone(), &*action, cx)
-                                })
-                        }),
+                                }),
+                        ),
                 )
             });
 

crates/workspace2/src/pane.rs 🔗

@@ -26,7 +26,7 @@ use std::{
     },
 };
 
-use ui::{menu_handle, prelude::*, Color, Icon, IconButton, IconElement, Tooltip};
+use ui::{prelude::*, right_click_menu, Color, Icon, IconButton, IconElement, Tooltip};
 use ui::{v_stack, ContextMenu};
 use util::truncate_and_remove_front;
 
@@ -1524,7 +1524,7 @@ impl Pane {
                     .children(close_right.then(|| close_icon())),
             );
 
-        menu_handle(ix).child(|_| tab).menu(|cx| {
+        right_click_menu(ix).trigger(tab).menu(|cx| {
             ContextMenu::build(cx, |menu, cx| {
                 menu.action(
                     "Close Active Item",

crates/workspace2/src/workspace2.rs 🔗

@@ -3638,6 +3638,8 @@ impl Render for Workspace {
             .items_start()
             .text_color(cx.theme().colors().text)
             .bg(cx.theme().colors().background)
+            .border()
+            .border_color(cx.theme().colors().border)
             .children(self.titlebar_item.clone())
             .child(
                 div()