Initial dedicated face pile element

Julia and Max Brunsfeld created

Rather than overload Flex with yet another special case, add a dedicated
element to handle this overlap instead

Co-Authored-By: Max Brunsfeld <max@zed.dev>

Change summary

crates/collab_ui/src/collab_titlebar_item.rs | 23 ++---
crates/collab_ui/src/collab_ui.rs            |  1 
crates/collab_ui/src/face_pile.rs            | 99 ++++++++++++++++++++++
styles/src/styleTree/workspace.ts            |  2 
4 files changed, 110 insertions(+), 15 deletions(-)

Detailed changes

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{
     collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover,
-    contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing,
+    contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
+    ToggleScreenSharing,
 };
 use call::{ActiveCall, ParticipantLocation, Room};
 use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
@@ -627,7 +628,7 @@ impl CollabTitlebarItem {
 
         let content = Stack::new()
             .with_children(user.avatar.as_ref().map(|avatar| {
-                let flex = Flex::row()
+                let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap)
                     .with_child(Self::render_face(avatar.clone(), avatar_style.clone()))
                     .with_children(
                         (|| {
@@ -652,16 +653,10 @@ impl CollabTitlebarItem {
                                         }
                                     })?;
 
-                                Some(
-                                    Container::new(Self::render_face(
-                                        avatar.clone(),
-                                        theme.workspace.titlebar.follower_avatar.clone(),
-                                    ))
-                                    .with_margin_left(
-                                        -1.0 * theme.workspace.titlebar.follower_avatar_overlap,
-                                    )
-                                    .boxed(),
-                                )
+                                Some(Self::render_face(
+                                    avatar.clone(),
+                                    theme.workspace.titlebar.follower_avatar.clone(),
+                                ))
                             }))
                         })()
                         .into_iter()
@@ -679,11 +674,11 @@ impl CollabTitlebarItem {
                             });
                     if followed_by_self {
                         let color = theme.editor.replica_selection_style(replica_id).selection;
-                        return flex.contained().with_background_color(color).boxed();
+                        return face_pile.contained().with_background_color(color).boxed();
                     }
                 }
 
-                flex.boxed()
+                face_pile.boxed()
             }))
             .with_children((|| {
                 let replica_id = replica_id?;

crates/collab_ui/src/collab_ui.rs 🔗

@@ -4,6 +4,7 @@ mod contact_finder;
 mod contact_list;
 mod contact_notification;
 mod contacts_popover;
+mod face_pile;
 mod incoming_call_notification;
 mod notifications;
 mod project_shared_notification;

crates/collab_ui/src/face_pile.rs 🔗

@@ -0,0 +1,99 @@
+use std::ops::Range;
+
+use gpui::{
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    json::ToJson,
+    serde_json::{self, json},
+    Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext,
+};
+
+pub(crate) struct FacePile {
+    overlap: f32,
+    faces: Vec<ElementBox>,
+}
+
+impl FacePile {
+    pub fn new(overlap: f32) -> FacePile {
+        FacePile {
+            overlap,
+            faces: Vec::new(),
+        }
+    }
+}
+
+impl Element for FacePile {
+    type LayoutState = ();
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        cx: &mut gpui::LayoutContext,
+    ) -> (Vector2F, Self::LayoutState) {
+        debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
+
+        let mut width = 0.;
+        for face in &mut self.faces {
+            width += face.layout(constraint, cx).x();
+        }
+        width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
+
+        (Vector2F::new(width, constraint.max.y()), ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        visible_bounds: RectF,
+        _layout: &mut Self::LayoutState,
+        cx: &mut PaintContext,
+    ) -> Self::PaintState {
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+
+        let origin_y = bounds.upper_right().y();
+        let mut origin_x = bounds.upper_right().x();
+
+        for face in self.faces.iter_mut().rev() {
+            let size = face.size();
+            origin_x -= size.x();
+            face.paint(vec2f(origin_x, origin_y), visible_bounds, cx);
+            origin_x += self.overlap;
+        }
+
+        ()
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &MeasurementContext,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &DebugContext,
+    ) -> serde_json::Value {
+        json!({
+            "type": "FacePile",
+            "bounds": bounds.to_json()
+        })
+    }
+}
+
+impl Extend<ElementBox> for FacePile {
+    fn extend<T: IntoIterator<Item = ElementBox>>(&mut self, children: T) {
+        self.faces.extend(children);
+    }
+}

styles/src/styleTree/workspace.ts 🔗

@@ -119,7 +119,7 @@ export default function workspace(colorScheme: ColorScheme) {
           width: 1,
         },
       },
-      followerAvatarOverlap: 4,
+      followerAvatarOverlap: 6,
       avatarRibbon: {
         height: 3,
         width: 12,