Associate collaborator avatars with "ribbons" corresponding to their cursor color

Nathan Sobo created

Change summary

crates/editor/src/element.rs        |  7 ---
crates/gpui/src/elements/align.rs   |  5 ++
crates/project/src/worktree.rs      |  2 
crates/theme/src/lib.rs             | 18 +++++++++
crates/workspace/src/lib.rs         | 59 ++++++++++++++++++++++--------
crates/zed/assets/themes/_base.toml |  3 +
6 files changed, 70 insertions(+), 24 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -371,12 +371,7 @@ impl EditorElement {
         let content_origin = bounds.origin() + layout.text_offset;
 
         for (replica_id, selections) in &layout.selections {
-            let style_ix = *replica_id as usize % (style.guest_selections.len() + 1);
-            let style = if style_ix == 0 {
-                &style.selection
-            } else {
-                &style.guest_selections[style_ix - 1]
-            };
+            let style = style.replica_selection_style(*replica_id);
 
             for selection in selections {
                 if selection.start != selection.end {

crates/gpui/src/elements/align.rs 🔗

@@ -25,6 +25,11 @@ impl Align {
         self
     }
 
+    pub fn bottom(mut self) -> Self {
+        self.alignment.set_y(1.0);
+        self
+    }
+
     pub fn left(mut self) -> Self {
         self.alignment.set_x(-1.0);
         self

crates/project/src/worktree.rs 🔗

@@ -63,7 +63,7 @@ pub enum Event {
     Closed,
 }
 
-#[derive(Debug)]
+#[derive(Clone, Debug)]
 pub struct Collaborator {
     pub user: Arc<User>,
     pub peer_id: PeerId,

crates/theme/src/lib.rs 🔗

@@ -45,6 +45,7 @@ pub struct Titlebar {
     pub height: f32,
     pub title: TextStyle,
     pub avatar_width: f32,
+    pub avatar_ribbon: AvatarRibbon,
     pub offline_icon: OfflineIcon,
     pub icon_color: Color,
     pub avatar: ImageStyle,
@@ -53,6 +54,14 @@ pub struct Titlebar {
     pub outdated_warning: ContainedText,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct AvatarRibbon {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub width: f32,
+    pub height: f32,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct OfflineIcon {
     #[serde(flatten)]
@@ -276,6 +285,15 @@ impl EditorStyle {
     pub fn placeholder_text(&self) -> &TextStyle {
         self.placeholder_text.as_ref().unwrap_or(&self.text)
     }
+
+    pub fn replica_selection_style(&self, replica_id: u16) -> &SelectionStyle {
+        let style_ix = replica_id as usize % (self.guest_selections.len() + 1);
+        if style_ix == 0 {
+            &self.selection
+        } else {
+            &self.guest_selections[style_ix - 1]
+        }
+    }
 }
 
 impl InputEditorStyle {

crates/workspace/src/lib.rs 🔗

@@ -968,11 +968,17 @@ impl Workspace {
                         Align::new(
                             Flex::row()
                                 .with_children(self.render_collaborators(theme, cx))
-                                .with_child(self.render_avatar(
-                                    self.user_store.read(cx).current_user().as_ref(),
-                                    theme,
-                                    cx,
-                                ))
+                                .with_child(
+                                    self.render_avatar(
+                                        self.user_store.read(cx).current_user().as_ref(),
+                                        self.project
+                                            .read(cx)
+                                            .active_worktree()
+                                            .map(|worktree| worktree.read(cx).replica_id()),
+                                        theme,
+                                        cx,
+                                    ),
+                                )
                                 .with_children(self.render_connection_status())
                                 .boxed(),
                         )
@@ -991,14 +997,19 @@ impl Workspace {
     fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
         let mut elements = Vec::new();
         if let Some(active_worktree) = self.project.read(cx).active_worktree() {
-            let users = active_worktree
+            let collaborators = active_worktree
                 .read(cx)
                 .collaborators()
                 .values()
-                .map(|c| c.user.clone())
+                .cloned()
                 .collect::<Vec<_>>();
-            for user in users {
-                elements.push(self.render_avatar(Some(&user), theme, cx));
+            for collaborator in collaborators {
+                elements.push(self.render_avatar(
+                    Some(&collaborator.user),
+                    Some(collaborator.replica_id),
+                    theme,
+                    cx,
+                ));
             }
         }
         elements
@@ -1007,21 +1018,37 @@ impl Workspace {
     fn render_avatar(
         &self,
         user: Option<&Arc<User>>,
+        replica_id: Option<u16>,
         theme: &Theme,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         if let Some(avatar) = user.and_then(|user| user.avatar.clone()) {
             ConstrainedBox::new(
-                Align::new(
-                    ConstrainedBox::new(
-                        Image::new(avatar)
-                            .with_style(theme.workspace.titlebar.avatar)
+                Stack::new()
+                    .with_child(
+                        ConstrainedBox::new(
+                            Image::new(avatar)
+                                .with_style(theme.workspace.titlebar.avatar)
+                                .boxed(),
+                        )
+                        .with_width(theme.workspace.titlebar.avatar_width)
+                        .aligned()
+                        .boxed(),
+                    )
+                    .with_child(
+                        Container::new(Empty::new().boxed())
+                            .with_style(theme.workspace.titlebar.avatar_ribbon.container)
+                            .with_background_color(replica_id.map_or(Default::default(), |id| {
+                                theme.editor.replica_selection_style(id).cursor
+                            }))
+                            .constrained()
+                            .with_width(theme.workspace.titlebar.avatar_ribbon.width)
+                            .with_height(theme.workspace.titlebar.avatar_ribbon.height)
+                            .aligned()
+                            .bottom()
                             .boxed(),
                     )
-                    .with_width(theme.workspace.titlebar.avatar_width)
                     .boxed(),
-                )
-                .boxed(),
             )
             .with_width(theme.workspace.right_sidebar.width)
             .boxed()

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

@@ -10,8 +10,9 @@ height = 32
 border = { width = 1, bottom = true, color = "$border.0" }
 title = "$text.0"
 avatar_width = 18
-icon_color = "$text.2.color"
 avatar = { corner_radius = 10, border = { width = 1, color = "#00000088" } }
+avatar_ribbon = { background = "#ff0000", height = 3, width = 12 }
+icon_color = "$text.2.color"
 outdated_warning = { extends = "$text.2", size = 13 }
 
 [workspace.titlebar.sign_in_prompt]