workspace: Make title bar pickers render nearby the trigger when mouse-triggered (#45361)

Danilo Leal created

From Zed's title bar, you can click on buttons to open three modal
pickers: remote projects, projects, and branches. All of these pickers
use the modal layer, which by default, renders them centered on the UI.
However, a UX issue we've been bothered by is that when you _click_ to
open them, they show up just way too far from where your mouse likely is
(nearby the trigger you just clicked). So, this PR introduces a
`ModalPlacement` enum to the modal layer, so that we can pick between
the "centered" and "anchored" options to render the picker. This way, we
can make the pickers use anchored positioning when triggered through a
mouse click and use the default centered positioning when triggered
through the keybinding.

One thing to note is that the anchored positioning here is not as
polished as regular popovers/dropdowns, because it simply uses the x and
y coordinates of the click to place the picker as opposed to using
GPUI's `Corner` enum, thus making them more connected to their triggers.
I chose to do it this way for now because it's a simpler and more
contained change, given it wouldn't require a tighter connection at the
code level between trigger and picker. But maybe we will want to do that
in the near future because we can bake in some other related behaviors
like automatically hiding the button trigger tooltip if the picker is
open and changing its text color to communicate which button triggered
the open picker.


https://github.com/user-attachments/assets/30d9c26a-24de-4702-8b7d-018b397f77e1

Release Notes:

- Improved the UX of title bar modal pickers (remote projects, projects,
and branches) by making them open closer to the trigger when triggering
them with the mouse.

Change summary

crates/title_bar/src/title_bar.rs   | 272 ++++++++++++++++++------------
crates/workspace/src/modal_layer.rs |  77 ++++++--
crates/workspace/src/workspace.rs   |  17 +
3 files changed, 240 insertions(+), 126 deletions(-)

Detailed changes

crates/title_bar/src/title_bar.rs 🔗

@@ -166,11 +166,11 @@ impl Render for TitleBar {
                                 .when(title_bar_settings.show_project_items, |title_bar| {
                                     title_bar
                                         .children(self.render_restricted_mode(cx))
-                                        .children(self.render_project_host(cx))
-                                        .child(self.render_project_name(cx))
+                                        .children(self.render_project_host(window, cx))
+                                        .child(self.render_project_name(window, cx))
                                 })
                                 .when(title_bar_settings.show_branch_name, |title_bar| {
-                                    title_bar.children(self.render_project_repo(cx))
+                                    title_bar.children(self.render_project_repo(window, cx))
                                 })
                         })
                 })
@@ -350,7 +350,14 @@ impl TitleBar {
             .next()
     }
 
-    fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    fn render_remote_project_connection(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        let workspace = self.workspace.clone();
+        let is_picker_open = self.is_picker_open(window, cx);
+
         let options = self.project.read(cx).remote_connection_options(cx)?;
         let host: SharedString = options.display_name().into();
 
@@ -395,7 +402,7 @@ impl TitleBar {
         let meta = SharedString::from(meta);
 
         Some(
-            ButtonLike::new("ssh-server-icon")
+            ButtonLike::new("remote_project")
                 .child(
                     h_flex()
                         .gap_2()
@@ -410,26 +417,35 @@ impl TitleBar {
                         )
                         .child(Label::new(nickname).size(LabelSize::Small).truncate()),
                 )
-                .tooltip(move |_window, cx| {
-                    Tooltip::with_meta(
-                        tooltip_title,
-                        Some(&OpenRemote {
-                            from_existing_connection: false,
-                            create_new_window: false,
-                        }),
-                        meta.clone(),
-                        cx,
-                    )
+                .when(!is_picker_open, |this| {
+                    this.tooltip(move |_window, cx| {
+                        Tooltip::with_meta(
+                            tooltip_title,
+                            Some(&OpenRemote {
+                                from_existing_connection: false,
+                                create_new_window: false,
+                            }),
+                            meta.clone(),
+                            cx,
+                        )
+                    })
                 })
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(
-                        OpenRemote {
-                            from_existing_connection: false,
-                            create_new_window: false,
-                        }
-                        .boxed_clone(),
-                        cx,
-                    );
+                .on_click(move |event, window, cx| {
+                    let position = event.position();
+                    let _ = workspace.update(cx, |this, cx| {
+                        this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+                            position,
+                        });
+
+                        window.dispatch_action(
+                            OpenRemote {
+                                from_existing_connection: false,
+                                create_new_window: false,
+                            }
+                            .boxed_clone(),
+                            cx,
+                        );
+                    });
                 })
                 .into_any_element(),
         )
@@ -481,9 +497,13 @@ impl TitleBar {
         }
     }
 
-    pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    pub fn render_project_host(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
         if self.project.read(cx).is_via_remote_server() {
-            return self.render_remote_project_connection(cx);
+            return self.render_remote_project_connection(window, cx);
         }
 
         if self.project.read(cx).is_disconnected(cx) {
@@ -491,7 +511,6 @@ impl TitleBar {
                 Button::new("disconnected", "Disconnected")
                     .disabled(true)
                     .color(Color::Disabled)
-                    .style(ButtonStyle::Subtle)
                     .label_size(LabelSize::Small)
                     .into_any_element(),
             );
@@ -504,15 +523,19 @@ impl TitleBar {
             .read(cx)
             .participant_indices()
             .get(&host_user.id)?;
+
         Some(
             Button::new("project_owner_trigger", host_user.github_login.clone())
                 .color(Color::Player(participant_index.0))
-                .style(ButtonStyle::Subtle)
                 .label_size(LabelSize::Small)
-                .tooltip(Tooltip::text(format!(
-                    "{} is sharing this project. Click to follow.",
-                    host_user.github_login
-                )))
+                .tooltip(move |_, cx| {
+                    let tooltip_title = format!(
+                        "{} is sharing this project. Click to follow.",
+                        host_user.github_login
+                    );
+
+                    Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx)
+                })
                 .on_click({
                     let host_peer_id = host.peer_id;
                     cx.listener(move |this, _, window, cx| {
@@ -527,7 +550,14 @@ impl TitleBar {
         )
     }
 
-    pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    pub fn render_project_name(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let workspace = self.workspace.clone();
+        let is_picker_open = self.is_picker_open(window, cx);
+
         let name = self.project_name(cx);
         let is_project_selected = name.is_some();
         let name = if let Some(name) = name {
@@ -537,19 +567,25 @@ impl TitleBar {
         };
 
         Button::new("project_name_trigger", name)
-            .when(!is_project_selected, |b| b.color(Color::Muted))
-            .style(ButtonStyle::Subtle)
             .label_size(LabelSize::Small)
-            .tooltip(move |_window, cx| {
-                Tooltip::for_action(
-                    "Recent Projects",
-                    &zed_actions::OpenRecent {
-                        create_new_window: false,
-                    },
-                    cx,
-                )
+            .when(!is_project_selected, |s| s.color(Color::Muted))
+            .when(!is_picker_open, |this| {
+                this.tooltip(move |_window, cx| {
+                    Tooltip::for_action(
+                        "Recent Projects",
+                        &zed_actions::OpenRecent {
+                            create_new_window: false,
+                        },
+                        cx,
+                    )
+                })
             })
-            .on_click(cx.listener(move |_, _, window, cx| {
+            .on_click(move |event, window, cx| {
+                let position = event.position();
+                let _ = workspace.update(cx, |this, _cx| {
+                    this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position })
+                });
+
                 window.dispatch_action(
                     OpenRecent {
                         create_new_window: false,
@@ -557,84 +593,102 @@ impl TitleBar {
                     .boxed_clone(),
                     cx,
                 );
-            }))
+            })
     }
 
-    pub fn render_project_repo(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
-        let settings = TitleBarSettings::get_global(cx);
+    pub fn render_project_repo(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
         let repository = self.project.read(cx).active_repository(cx)?;
         let repository_count = self.project.read(cx).repositories(cx).len();
         let workspace = self.workspace.upgrade()?;
-        let repo = repository.read(cx);
-        let branch_name = repo
-            .branch
-            .as_ref()
-            .map(|branch| branch.name())
-            .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
-            .or_else(|| {
-                repo.head_commit.as_ref().map(|commit| {
-                    commit
-                        .sha
-                        .chars()
-                        .take(MAX_SHORT_SHA_LENGTH)
-                        .collect::<String>()
-                })
-            })?;
-        let project_name = self.project_name(cx);
-        let repo_name = repo
-            .work_directory_abs_path
-            .file_name()
-            .and_then(|name| name.to_str())
-            .map(SharedString::new);
-        let show_repo_name =
-            repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
-        let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
-            format!("{repo_name}/{branch_name}")
-        } else {
-            branch_name
+
+        let (branch_name, icon_info) = {
+            let repo = repository.read(cx);
+            let branch_name = repo
+                .branch
+                .as_ref()
+                .map(|branch| branch.name())
+                .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
+                .or_else(|| {
+                    repo.head_commit.as_ref().map(|commit| {
+                        commit
+                            .sha
+                            .chars()
+                            .take(MAX_SHORT_SHA_LENGTH)
+                            .collect::<String>()
+                    })
+                });
+
+            let branch_name = branch_name?;
+
+            let project_name = self.project_name(cx);
+            let repo_name = repo
+                .work_directory_abs_path
+                .file_name()
+                .and_then(|name| name.to_str())
+                .map(SharedString::new);
+            let show_repo_name =
+                repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
+            let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
+                format!("{repo_name}/{branch_name}")
+            } else {
+                branch_name
+            };
+
+            let status = repo.status_summary();
+            let tracked = status.index + status.worktree;
+            let icon_info = if status.conflict > 0 {
+                (IconName::Warning, Color::VersionControlConflict)
+            } else if tracked.modified > 0 {
+                (IconName::SquareDot, Color::VersionControlModified)
+            } else if tracked.added > 0 || status.untracked > 0 {
+                (IconName::SquarePlus, Color::VersionControlAdded)
+            } else if tracked.deleted > 0 {
+                (IconName::SquareMinus, Color::VersionControlDeleted)
+            } else {
+                (IconName::GitBranch, Color::Muted)
+            };
+
+            (branch_name, icon_info)
         };
 
+        let is_picker_open = self.is_picker_open(window, cx);
+        let settings = TitleBarSettings::get_global(cx);
+
         Some(
             Button::new("project_branch_trigger", branch_name)
-                .color(Color::Muted)
-                .style(ButtonStyle::Subtle)
                 .label_size(LabelSize::Small)
-                .tooltip(move |_window, cx| {
-                    Tooltip::with_meta(
-                        "Recent Branches",
-                        Some(&zed_actions::git::Branch),
-                        "Local branches only",
-                        cx,
-                    )
-                })
-                .on_click(move |_, window, cx| {
-                    let _ = workspace.update(cx, |this, cx| {
-                        window.focus(&this.active_pane().focus_handle(cx), cx);
-                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
-                    });
+                .color(Color::Muted)
+                .when(!is_picker_open, |this| {
+                    this.tooltip(move |_window, cx| {
+                        Tooltip::with_meta(
+                            "Recent Branches",
+                            Some(&zed_actions::git::Branch),
+                            "Local branches only",
+                            cx,
+                        )
+                    })
                 })
                 .when(settings.show_branch_icon, |branch_button| {
-                    let (icon, icon_color) = {
-                        let status = repo.status_summary();
-                        let tracked = status.index + status.worktree;
-                        if status.conflict > 0 {
-                            (IconName::Warning, Color::VersionControlConflict)
-                        } else if tracked.modified > 0 {
-                            (IconName::SquareDot, Color::VersionControlModified)
-                        } else if tracked.added > 0 || status.untracked > 0 {
-                            (IconName::SquarePlus, Color::VersionControlAdded)
-                        } else if tracked.deleted > 0 {
-                            (IconName::SquareMinus, Color::VersionControlDeleted)
-                        } else {
-                            (IconName::GitBranch, Color::Muted)
-                        }
-                    };
-
+                    let (icon, icon_color) = icon_info;
                     branch_button
                         .icon(icon)
                         .icon_position(IconPosition::Start)
                         .icon_color(icon_color)
                         .icon_size(IconSize::Indicator)
+                })
+                .on_click(move |event, window, cx| {
+                    let position = event.position();
+                    let _ = workspace.update(cx, |this, cx| {
+                        this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+                            position,
+                        });
+                        window.focus(&this.active_pane().focus_handle(cx), cx);
+                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+                    });
                 }),
         )
     }
@@ -726,7 +780,7 @@ impl TitleBar {
 
     pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
         let client = self.client.clone();
-        Button::new("sign_in", "Sign in")
+        Button::new("sign_in", "Sign In")
             .label_size(LabelSize::Small)
             .on_click(move |_, window, cx| {
                 let client = client.clone();
@@ -848,4 +902,10 @@ impl TitleBar {
             })
             .anchor(gpui::Corner::TopRight)
     }
+
+    fn is_picker_open(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+        self.workspace
+            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
+            .unwrap_or(false)
+    }
 }

crates/workspace/src/modal_layer.rs 🔗

@@ -1,9 +1,18 @@
 use gpui::{
     AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView,
-    MouseButton, Subscription,
+    MouseButton, Pixels, Point, Subscription,
 };
 use ui::prelude::*;
 
+#[derive(Debug, Clone, Copy, Default)]
+pub enum ModalPlacement {
+    #[default]
+    Centered,
+    Anchored {
+        position: Point<Pixels>,
+    },
+}
+
 #[derive(Debug)]
 pub enum DismissDecision {
     Dismiss(bool),
@@ -58,6 +67,7 @@ pub struct ActiveModal {
     _subscriptions: [Subscription; 2],
     previous_focus_handle: Option<FocusHandle>,
     focus_handle: FocusHandle,
+    placement: ModalPlacement,
 }
 
 pub struct ModalLayer {
@@ -87,6 +97,19 @@ impl ModalLayer {
     where
         V: ModalView,
         B: FnOnce(&mut Window, &mut Context<V>) -> V,
+    {
+        self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view);
+    }
+
+    pub fn toggle_modal_with_placement<V, B>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        placement: ModalPlacement,
+        build_view: B,
+    ) where
+        V: ModalView,
+        B: FnOnce(&mut Window, &mut Context<V>) -> V,
     {
         if let Some(active_modal) = &self.active_modal {
             let is_close = active_modal.modal.view().downcast::<V>().is_ok();
@@ -96,12 +119,17 @@ impl ModalLayer {
             }
         }
         let new_modal = cx.new(|cx| build_view(window, cx));
-        self.show_modal(new_modal, window, cx);
+        self.show_modal(new_modal, placement, window, cx);
         cx.emit(ModalOpenedEvent);
     }
 
-    fn show_modal<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
-    where
+    fn show_modal<V>(
+        &mut self,
+        new_modal: Entity<V>,
+        placement: ModalPlacement,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) where
         V: ModalView,
     {
         let focus_handle = cx.focus_handle();
@@ -123,6 +151,7 @@ impl ModalLayer {
             ],
             previous_focus_handle: window.focused(cx),
             focus_handle,
+            placement,
         });
         cx.defer_in(window, move |_, window, cx| {
             window.focus(&new_modal.focus_handle(cx), cx);
@@ -183,6 +212,30 @@ impl Render for ModalLayer {
             return active_modal.modal.view().into_any_element();
         }
 
+        let content = h_flex()
+            .occlude()
+            .child(active_modal.modal.view())
+            .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                cx.stop_propagation();
+            });
+
+        let positioned = match active_modal.placement {
+            ModalPlacement::Centered => v_flex()
+                .h(px(0.0))
+                .top_20()
+                .items_center()
+                .track_focus(&active_modal.focus_handle)
+                .child(content)
+                .into_any_element(),
+            ModalPlacement::Anchored { position } => div()
+                .absolute()
+                .left(position.x)
+                .top(position.y - px(20.))
+                .track_focus(&active_modal.focus_handle)
+                .child(content)
+                .into_any_element(),
+        };
+
         div()
             .absolute()
             .size_full()
@@ -199,21 +252,7 @@ impl Render for ModalLayer {
                     this.hide_modal(window, cx);
                 }),
             )
-            .child(
-                v_flex()
-                    .h(px(0.0))
-                    .top_20()
-                    .items_center()
-                    .track_focus(&active_modal.focus_handle)
-                    .child(
-                        h_flex()
-                            .occlude()
-                            .child(active_modal.modal.view())
-                            .on_mouse_down(MouseButton::Left, |_, _, cx| {
-                                cx.stop_propagation();
-                            }),
-                    ),
-            )
+            .child(positioned)
             .into_any_element()
     }
 }

crates/workspace/src/workspace.rs 🔗

@@ -1204,6 +1204,7 @@ pub struct Workspace {
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
     utility_panes: UtilityPaneState,
+    next_modal_placement: Option<ModalPlacement>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -1620,6 +1621,7 @@ impl Workspace {
             last_open_dock_positions: Vec::new(),
             removing: false,
             utility_panes: UtilityPaneState::default(),
+            next_modal_placement: None,
         }
     }
 
@@ -6326,12 +6328,25 @@ impl Workspace {
         self.modal_layer.read(cx).active_modal()
     }
 
+    pub fn is_modal_open<V: 'static>(&self, cx: &App) -> bool {
+        self.modal_layer.read(cx).active_modal::<V>().is_some()
+    }
+
+    pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) {
+        self.next_modal_placement = Some(placement);
+    }
+
+    fn take_next_modal_placement(&mut self) -> ModalPlacement {
+        self.next_modal_placement.take().unwrap_or_default()
+    }
+
     pub fn toggle_modal<V: ModalView, B>(&mut self, window: &mut Window, cx: &mut App, build: B)
     where
         B: FnOnce(&mut Window, &mut Context<V>) -> V,
     {
+        let placement = self.take_next_modal_placement();
         self.modal_layer.update(cx, |modal_layer, cx| {
-            modal_layer.toggle_modal(window, cx, build)
+            modal_layer.toggle_modal_with_placement(window, cx, placement, build)
         })
     }