diff --git a/crates/collab_ui2/src/collab_titlebar_item.rs b/crates/collab_ui2/src/collab_titlebar_item.rs index f18e4cb2db..2cdf32ca36 100644 --- a/crates/collab_ui2/src/collab_titlebar_item.rs +++ b/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 + + pub fn render_project_owner(&self, cx: &mut ViewContext) -> Option { + // 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) -> 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) -> Option { + 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, diff --git a/crates/ui2/src/components.rs b/crates/ui2/src/components.rs index be95fc1fab..17271de48d 100644 --- a/crates/ui2/src/components.rs +++ b/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::*; diff --git a/crates/ui2/src/components/button/button_like.rs b/crates/ui2/src/components/button/button_like.rs index 89725a9821..678e308f90 100644 --- a/crates/ui2/src/components/button/button_like.rs +++ b/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) } diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index f071d188a1..562639ec58 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/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 { - id: ElementId, - child_builder: Option AnyElement + 'static>>, - menu_builder: Option View + 'static>>, - anchor: Option, - attach: Option, -} - -impl MenuHandle { - pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { - self.menu_builder = Some(Rc::new(f)); - self - } - - pub fn child(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(id: impl Into) -> MenuHandle { - MenuHandle { - id: id.into(), - child_builder: None, - menu_builder: None, - anchor: None, - attach: None, - } -} - -pub struct MenuHandleState { - menu: Rc>>>, - position: Rc>>, - child_layout_id: Option, - child_element: Option, - menu_element: Option, -} - -impl Element for MenuHandle { - type State = MenuHandleState; - - fn layout( - &mut self, - element_state: Option, - 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, - 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 IntoElement for MenuHandle { - type Element = Self; - - fn element_id(&self) -> Option { - Some(self.id.clone()) - } - - fn into_element(self) -> Self::Element { - self - } -} diff --git a/crates/ui2/src/components/popover_menu.rs b/crates/ui2/src/components/popover_menu.rs new file mode 100644 index 0000000000..4b5144e7c7 --- /dev/null +++ b/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 PopoverTrigger for T {} + +pub struct PopoverMenu { + id: ElementId, + child_builder: Option< + Box< + dyn FnOnce( + Rc>>>, + Option View + 'static>>, + ) -> AnyElement + + 'static, + >, + >, + menu_builder: Option View + 'static>>, + anchor: AnchorCorner, + attach: Option, + offset: Option>, +} + +impl PopoverMenu { + pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { + self.menu_builder = Some(Rc::new(f)); + self + } + + pub fn trigger(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) -> 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 { + 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(id: impl Into) -> PopoverMenu { + PopoverMenu { + id: id.into(), + child_builder: None, + menu_builder: None, + anchor: AnchorCorner::TopLeft, + attach: None, + offset: None, + } +} + +pub struct PopoverMenuState { + child_layout_id: Option, + child_element: Option, + child_bounds: Option>, + menu_element: Option, + menu: Rc>>>, +} + +impl Element for PopoverMenu { + type State = PopoverMenuState; + + fn layout( + &mut self, + element_state: Option, + 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, + 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 IntoElement for PopoverMenu { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.id.clone()) + } + + fn into_element(self) -> Self::Element { + self + } +} diff --git a/crates/ui2/src/components/right_click_menu.rs b/crates/ui2/src/components/right_click_menu.rs new file mode 100644 index 0000000000..27c4fdab96 --- /dev/null +++ b/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 { + id: ElementId, + child_builder: Option AnyElement + 'static>>, + menu_builder: Option View + 'static>>, + anchor: Option, + attach: Option, +} + +impl RightClickMenu { + pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { + self.menu_builder = Some(Rc::new(f)); + self + } + + pub fn trigger(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(id: impl Into) -> RightClickMenu { + RightClickMenu { + id: id.into(), + child_builder: None, + menu_builder: None, + anchor: None, + attach: None, + } +} + +pub struct MenuHandleState { + menu: Rc>>>, + position: Rc>>, + child_layout_id: Option, + child_element: Option, + menu_element: Option, +} + +impl Element for RightClickMenu { + type State = MenuHandleState; + + fn layout( + &mut self, + element_state: Option, + 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, + 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 IntoElement for RightClickMenu { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.id.clone()) + } + + fn into_element(self) -> Self::Element { + self + } +} diff --git a/crates/ui2/src/components/stories/context_menu.rs b/crates/ui2/src/components/stories/context_menu.rs index d5fb94df4f..dd1fe8d565 100644 --- a/crates/ui2/src/components/stories/context_menu.rs +++ b/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")), diff --git a/crates/workspace2/src/dock.rs b/crates/workspace2/src/dock.rs index 06fdf7f9c1..437e7c0192 100644 --- a/crates/workspace2/src/dock.rs +++ b/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) - }) - }), + }), + ), ) }); diff --git a/crates/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index 8f30d0573f..e4ef1b1545 100644 --- a/crates/workspace2/src/pane.rs +++ b/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", diff --git a/crates/workspace2/src/workspace2.rs b/crates/workspace2/src/workspace2.rs index 07c12a5f06..5dcec2cabd 100644 --- a/crates/workspace2/src/workspace2.rs +++ b/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()