Show borders around avatars and panes to indicate following state

Max Brunsfeld created

Change summary

crates/theme/src/theme.rs           |  1 
crates/workspace/src/pane_group.rs  | 61 +++++++++++++++++++++++++-----
crates/workspace/src/workspace.rs   | 38 ++++++++++++++----
crates/zed/assets/themes/_base.toml |  1 
4 files changed, 82 insertions(+), 19 deletions(-)

Detailed changes

crates/theme/src/theme.rs 🔗

@@ -35,6 +35,7 @@ pub struct Workspace {
     pub tab: Tab,
     pub active_tab: Tab,
     pub pane_divider: Border,
+    pub leader_border_opacity: f32,
     pub left_sidebar: Sidebar,
     pub right_sidebar: Sidebar,
     pub status_bar: StatusBar,

crates/workspace/src/pane_group.rs 🔗

@@ -1,9 +1,11 @@
+use crate::{FollowerStatesByLeader, Pane};
 use anyhow::{anyhow, Result};
-use gpui::{elements::*, Axis, ViewHandle};
+use client::PeerId;
+use collections::HashMap;
+use gpui::{elements::*, Axis, Border, ViewHandle};
+use project::Collaborator;
 use theme::Theme;
 
-use crate::Pane;
-
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct PaneGroup {
     root: Member,
@@ -47,8 +49,13 @@ impl PaneGroup {
         }
     }
 
-    pub fn render<'a>(&self, theme: &Theme) -> ElementBox {
-        self.root.render(theme)
+    pub(crate) fn render<'a>(
+        &self,
+        theme: &Theme,
+        follower_states: &FollowerStatesByLeader,
+        collaborators: &HashMap<PeerId, Collaborator>,
+    ) -> ElementBox {
+        self.root.render(theme, follower_states, collaborators)
     }
 }
 
@@ -80,10 +87,39 @@ impl Member {
         Member::Axis(PaneAxis { axis, members })
     }
 
-    pub fn render(&self, theme: &Theme) -> ElementBox {
+    pub fn render(
+        &self,
+        theme: &Theme,
+        follower_states: &FollowerStatesByLeader,
+        collaborators: &HashMap<PeerId, Collaborator>,
+    ) -> ElementBox {
         match self {
-            Member::Pane(pane) => ChildView::new(pane).boxed(),
-            Member::Axis(axis) => axis.render(theme),
+            Member::Pane(pane) => {
+                let mut border = Border::default();
+                let leader = follower_states
+                    .iter()
+                    .find_map(|(leader_id, follower_states)| {
+                        if follower_states.contains_key(pane) {
+                            Some(leader_id)
+                        } else {
+                            None
+                        }
+                    })
+                    .and_then(|leader_id| collaborators.get(leader_id));
+                if let Some(leader) = leader {
+                    let leader_color = theme
+                        .editor
+                        .replica_selection_style(leader.replica_id)
+                        .cursor;
+                    border = Border::all(1.0, leader_color);
+                    border
+                        .color
+                        .fade_out(1. - theme.workspace.leader_border_opacity);
+                    border.overlay = true;
+                }
+                ChildView::new(pane).contained().with_border(border).boxed()
+            }
+            Member::Axis(axis) => axis.render(theme, follower_states, collaborators),
         }
     }
 }
@@ -172,11 +208,16 @@ impl PaneAxis {
         }
     }
 
-    fn render<'a>(&self, theme: &Theme) -> ElementBox {
+    fn render(
+        &self,
+        theme: &Theme,
+        follower_state: &FollowerStatesByLeader,
+        collaborators: &HashMap<PeerId, Collaborator>,
+    ) -> ElementBox {
         let last_member_ix = self.members.len() - 1;
         Flex::new(self.axis)
             .with_children(self.members.iter().enumerate().map(|(ix, member)| {
-                let mut member = member.render(theme);
+                let mut member = member.render(theme, follower_state, collaborators);
                 if ix < last_member_ix {
                     let mut border = theme.workspace.pane_divider;
                     border.left = false;

crates/workspace/src/workspace.rs 🔗

@@ -20,9 +20,9 @@ use gpui::{
     json::{self, to_string_pretty, ToJson},
     keymap::Binding,
     platform::{CursorStyle, WindowOptions},
-    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Entity, ImageData,
-    ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, ClipboardItem, Entity,
+    ImageData, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use log::error;
@@ -613,7 +613,7 @@ pub struct Workspace {
     status_bar: ViewHandle<StatusBar>,
     project: ModelHandle<Project>,
     leader_state: LeaderState,
-    follower_states_by_leader: HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>,
+    follower_states_by_leader: FollowerStatesByLeader,
     _observe_current_user: Task<()>,
 }
 
@@ -622,6 +622,8 @@ struct LeaderState {
     followers: HashSet<PeerId>,
 }
 
+type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
+
 #[derive(Default)]
 struct FollowerState {
     active_view_id: Option<u64>,
@@ -1262,6 +1264,7 @@ impl Workspace {
         cx: &mut ViewContext<Self>,
     ) -> Option<PeerId> {
         for (leader_id, states_by_pane) in &mut self.follower_states_by_leader {
+            let leader_id = *leader_id;
             if let Some(state) = states_by_pane.remove(&pane) {
                 for (_, item) in state.items_by_leader_view_id {
                     if let FollowerItem::Loaded(item) = item {
@@ -1270,6 +1273,7 @@ impl Workspace {
                 }
 
                 if states_by_pane.is_empty() {
+                    self.follower_states_by_leader.remove(&leader_id);
                     if let Some(project_id) = self.project.read(cx).remote_id() {
                         self.client
                             .send(proto::Unfollow {
@@ -1281,7 +1285,7 @@ impl Workspace {
                 }
 
                 cx.notify();
-                return Some(*leader_id);
+                return Some(leader_id);
             }
         }
         None
@@ -1420,17 +1424,25 @@ impl Workspace {
         theme: &Theme,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
+        let replica_color = theme.editor.replica_selection_style(replica_id).cursor;
+        let is_followed = peer_id.map_or(false, |peer_id| {
+            self.follower_states_by_leader.contains_key(&peer_id)
+        });
+        let mut avatar_style = theme.workspace.titlebar.avatar;
+        if is_followed {
+            avatar_style.border = Border::all(1.0, replica_color);
+        }
         let content = Stack::new()
             .with_child(
                 Image::new(avatar)
-                    .with_style(theme.workspace.titlebar.avatar)
+                    .with_style(avatar_style)
                     .constrained()
                     .with_width(theme.workspace.titlebar.avatar_width)
                     .aligned()
                     .boxed(),
             )
             .with_child(
-                AvatarRibbon::new(theme.editor.replica_selection_style(replica_id).cursor)
+                AvatarRibbon::new(replica_color)
                     .constrained()
                     .with_width(theme.workspace.titlebar.avatar_ribbon.width)
                     .with_height(theme.workspace.titlebar.avatar_ribbon.height)
@@ -1800,8 +1812,16 @@ impl View for Workspace {
                                 content.add_child(
                                     Flex::column()
                                         .with_child(
-                                            Flexible::new(1., true, self.center.render(&theme))
-                                                .boxed(),
+                                            Flexible::new(
+                                                1.,
+                                                true,
+                                                self.center.render(
+                                                    &theme,
+                                                    &self.follower_states_by_leader,
+                                                    self.project.read(cx).collaborators(),
+                                                ),
+                                            )
+                                            .boxed(),
                                         )
                                         .with_child(ChildView::new(&self.status_bar).boxed())
                                         .flexible(1., true)

crates/zed/assets/themes/_base.toml 🔗

@@ -4,6 +4,7 @@ base = { family = "Zed Sans", size = 14 }
 [workspace]
 background = "$surface.0"
 pane_divider = { width = 1, color = "$border.0" }
+leader_border_opacity = 0.6
 
 [workspace.titlebar]
 height = 32