Rework `Avatar` indicator to be more general-purpose (#4073)

Marshall Bowers created

This PR reworks the way we add indicators to `Avatar`s to make them more
general-purpose.

Previously we had logic specific to the availability indicator embedded
in the `Avatar` component, which made it unwieldy to repurpose for
something else.

Now the `indicator` is just a slot that we can put anything into.

Release Notes:

- N/A

Change summary

crates/collab_ui/src/collab_panel.rs                             |  84 
crates/ui/src/components/avatar.rs                               | 138 -
crates/ui/src/components/avatar/avatar.rs                        | 122 +
crates/ui/src/components/avatar/avatar_availability_indicator.rs |  48 
crates/ui/src/components/stories/avatar.rs                       |   6 
5 files changed, 222 insertions(+), 176 deletions(-)

Detailed changes

crates/collab_ui/src/collab_panel.rs 🔗

@@ -31,8 +31,8 @@ use smallvec::SmallVec;
 use std::{mem, sync::Arc};
 use theme::{ActiveTheme, ThemeSettings};
 use ui::{
-    prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconName, IconSize, Label,
-    ListHeader, ListItem, Tooltip,
+    prelude::*, Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Icon, IconButton,
+    IconName, IconSize, Label, ListHeader, ListItem, Tooltip,
 };
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
@@ -2000,43 +2000,49 @@ impl CollabPanel {
         let busy = contact.busy || calling;
         let user_id = contact.user.id;
         let github_login = SharedString::from(contact.user.github_login.clone());
-        let item =
-            ListItem::new(github_login.clone())
-                .indent_level(1)
-                .indent_step_size(px(20.))
-                .selected(is_selected)
-                .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
-                .child(
-                    h_flex()
-                        .w_full()
-                        .justify_between()
-                        .child(Label::new(github_login.clone()))
-                        .when(calling, |el| {
-                            el.child(Label::new("Calling").color(Color::Muted))
-                        })
-                        .when(!calling, |el| {
-                            el.child(
-                                IconButton::new("remove_contact", IconName::Close)
-                                    .icon_color(Color::Muted)
-                                    .visible_on_hover("")
-                                    .tooltip(|cx| Tooltip::text("Remove Contact", cx))
-                                    .on_click(cx.listener({
-                                        let github_login = github_login.clone();
-                                        move |this, _, cx| {
-                                            this.remove_contact(user_id, &github_login, cx);
-                                        }
-                                    })),
-                            )
-                        }),
-                )
-                .start_slot(
-                    // todo handle contacts with no avatar
-                    Avatar::new(contact.user.avatar_uri.clone())
-                        .availability_indicator(if online { Some(!busy) } else { None }),
-                )
-                .when(online && !busy, |el| {
-                    el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
-                });
+        let item = ListItem::new(github_login.clone())
+            .indent_level(1)
+            .indent_step_size(px(20.))
+            .selected(is_selected)
+            .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
+            .child(
+                h_flex()
+                    .w_full()
+                    .justify_between()
+                    .child(Label::new(github_login.clone()))
+                    .when(calling, |el| {
+                        el.child(Label::new("Calling").color(Color::Muted))
+                    })
+                    .when(!calling, |el| {
+                        el.child(
+                            IconButton::new("remove_contact", IconName::Close)
+                                .icon_color(Color::Muted)
+                                .visible_on_hover("")
+                                .tooltip(|cx| Tooltip::text("Remove Contact", cx))
+                                .on_click(cx.listener({
+                                    let github_login = github_login.clone();
+                                    move |this, _, cx| {
+                                        this.remove_contact(user_id, &github_login, cx);
+                                    }
+                                })),
+                        )
+                    }),
+            )
+            .start_slot(
+                // todo handle contacts with no avatar
+                Avatar::new(contact.user.avatar_uri.clone())
+                    .indicator::<AvatarAvailabilityIndicator>(if online {
+                        Some(AvatarAvailabilityIndicator::new(match busy {
+                            true => ui::Availability::Busy,
+                            false => ui::Availability::Free,
+                        }))
+                    } else {
+                        None
+                    }),
+            )
+            .when(online && !busy, |el| {
+                el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx)))
+            });
 
         div()
             .id(github_login.clone())

crates/ui/src/components/avatar.rs 🔗

@@ -1,135 +1,5 @@
-use crate::prelude::*;
-use gpui::{img, Hsla, ImageSource, Img, IntoElement, Styled};
+mod avatar;
+mod avatar_availability_indicator;
 
-/// The shape of an [`Avatar`].
-#[derive(Debug, Default, PartialEq, Clone)]
-pub enum AvatarShape {
-    /// The avatar is shown in a circle.
-    #[default]
-    Circle,
-    /// The avatar is shown in a rectangle with rounded corners.
-    RoundedRectangle,
-}
-
-/// An element that renders a user avatar with customizable appearance options.
-///
-/// # Examples
-///
-/// ```
-/// use ui::{Avatar, AvatarShape};
-///
-/// Avatar::new("path/to/image.png")
-///     .shape(AvatarShape::Circle)
-///     .grayscale(true)
-///     .border_color(gpui::red());
-/// ```
-#[derive(IntoElement)]
-pub struct Avatar {
-    image: Img,
-    size: Option<Pixels>,
-    border_color: Option<Hsla>,
-    is_available: Option<bool>,
-}
-
-impl RenderOnce for Avatar {
-    fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
-        if self.image.style().corner_radii.top_left.is_none() {
-            self = self.shape(AvatarShape::Circle);
-        }
-
-        let size = self.size.unwrap_or_else(|| cx.rem_size());
-
-        div()
-            .size(size + px(2.))
-            .map(|mut div| {
-                div.style().corner_radii = self.image.style().corner_radii.clone();
-                div
-            })
-            .when_some(self.border_color, |this, color| {
-                this.border().border_color(color)
-            })
-            .child(
-                self.image
-                    .size(size)
-                    .bg(cx.theme().colors().ghost_element_background),
-            )
-            .children(self.is_available.map(|is_free| {
-                // HACK: non-integer sizes result in oval indicators.
-                let indicator_size = (size * 0.4).round();
-
-                div()
-                    .absolute()
-                    .z_index(1)
-                    .bg(if is_free {
-                        cx.theme().status().created
-                    } else {
-                        cx.theme().status().deleted
-                    })
-                    .size(indicator_size)
-                    .rounded(indicator_size)
-                    .bottom_0()
-                    .right_0()
-            }))
-    }
-}
-
-impl Avatar {
-    pub fn new(src: impl Into<ImageSource>) -> Self {
-        Avatar {
-            image: img(src),
-            is_available: None,
-            border_color: None,
-            size: None,
-        }
-    }
-
-    /// Sets the shape of the avatar image.
-    ///
-    /// This method allows the shape of the avatar to be specified using a [`Shape`].
-    /// It modifies the corner radius of the image to match the specified shape.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use ui::{Avatar, AvatarShape};
-    ///
-    /// Avatar::new("path/to/image.png").shape(AvatarShape::Circle);
-    /// ```
-    pub fn shape(mut self, shape: AvatarShape) -> Self {
-        self.image = match shape {
-            AvatarShape::Circle => self.image.rounded_full(),
-            AvatarShape::RoundedRectangle => self.image.rounded_md(),
-        };
-        self
-    }
-
-    /// Applies a grayscale filter to the avatar image.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use ui::{Avatar, AvatarShape};
-    ///
-    /// let avatar = Avatar::new("path/to/image.png").grayscale(true);
-    /// ```
-    pub fn grayscale(mut self, grayscale: bool) -> Self {
-        self.image = self.image.grayscale(grayscale);
-        self
-    }
-
-    pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
-        self.border_color = Some(color.into());
-        self
-    }
-
-    pub fn availability_indicator(mut self, is_available: impl Into<Option<bool>>) -> Self {
-        self.is_available = is_available.into();
-        self
-    }
-
-    /// Size overrides the avatar size. By default they are 1rem.
-    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
-        self.size = size.into();
-        self
-    }
-}
+pub use avatar::*;
+pub use avatar_availability_indicator::*;

crates/ui/src/components/avatar/avatar.rs 🔗

@@ -0,0 +1,122 @@
+use crate::prelude::*;
+use gpui::{img, AnyElement, Hsla, ImageSource, Img, IntoElement, Styled};
+
+/// The shape of an [`Avatar`].
+#[derive(Debug, Default, PartialEq, Clone)]
+pub enum AvatarShape {
+    /// The avatar is shown in a circle.
+    #[default]
+    Circle,
+    /// The avatar is shown in a rectangle with rounded corners.
+    RoundedRectangle,
+}
+
+/// An element that renders a user avatar with customizable appearance options.
+///
+/// # Examples
+///
+/// ```
+/// use ui::{Avatar, AvatarShape};
+///
+/// Avatar::new("path/to/image.png")
+///     .shape(AvatarShape::Circle)
+///     .grayscale(true)
+///     .border_color(gpui::red());
+/// ```
+#[derive(IntoElement)]
+pub struct Avatar {
+    image: Img,
+    size: Option<Pixels>,
+    border_color: Option<Hsla>,
+    indicator: Option<AnyElement>,
+}
+
+impl Avatar {
+    pub fn new(src: impl Into<ImageSource>) -> Self {
+        Avatar {
+            image: img(src),
+            size: None,
+            border_color: None,
+            indicator: None,
+        }
+    }
+
+    /// Sets the shape of the avatar image.
+    ///
+    /// This method allows the shape of the avatar to be specified using a [`Shape`].
+    /// It modifies the corner radius of the image to match the specified shape.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::{Avatar, AvatarShape};
+    ///
+    /// Avatar::new("path/to/image.png").shape(AvatarShape::Circle);
+    /// ```
+    pub fn shape(mut self, shape: AvatarShape) -> Self {
+        self.image = match shape {
+            AvatarShape::Circle => self.image.rounded_full(),
+            AvatarShape::RoundedRectangle => self.image.rounded_md(),
+        };
+        self
+    }
+
+    /// Applies a grayscale filter to the avatar image.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::{Avatar, AvatarShape};
+    ///
+    /// let avatar = Avatar::new("path/to/image.png").grayscale(true);
+    /// ```
+    pub fn grayscale(mut self, grayscale: bool) -> Self {
+        self.image = self.image.grayscale(grayscale);
+        self
+    }
+
+    pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
+        self.border_color = Some(color.into());
+        self
+    }
+
+    /// Size overrides the avatar size. By default they are 1rem.
+    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.size = size.into();
+        self
+    }
+
+    pub fn indicator<E: IntoElement>(mut self, indicator: impl Into<Option<E>>) -> Self {
+        self.indicator = indicator.into().map(IntoElement::into_any_element);
+        self
+    }
+}
+
+impl RenderOnce for Avatar {
+    fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
+        if self.image.style().corner_radii.top_left.is_none() {
+            self = self.shape(AvatarShape::Circle);
+        }
+
+        let size = self.size.unwrap_or_else(|| cx.rem_size());
+
+        div()
+            .size(size + px(2.))
+            .map(|mut div| {
+                div.style().corner_radii = self.image.style().corner_radii.clone();
+                div
+            })
+            .when_some(self.border_color, |this, color| {
+                this.border().border_color(color)
+            })
+            .child(
+                self.image
+                    .size(size)
+                    .bg(cx.theme().colors().ghost_element_background),
+            )
+            .children(
+                self.indicator
+                    .map(|indicator| div().z_index(1).child(indicator)),
+            )
+    }
+}

crates/ui/src/components/avatar/avatar_availability_indicator.rs 🔗

@@ -0,0 +1,48 @@
+use crate::prelude::*;
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum Availability {
+    Free,
+    Busy,
+}
+
+#[derive(IntoElement)]
+pub struct AvatarAvailabilityIndicator {
+    availability: Availability,
+    avatar_size: Option<Pixels>,
+}
+
+impl AvatarAvailabilityIndicator {
+    pub fn new(availability: Availability) -> Self {
+        Self {
+            availability,
+            avatar_size: None,
+        }
+    }
+
+    /// Sets the size of the [`Avatar`] this indicator appears on.
+    pub fn avatar_size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.avatar_size = size.into();
+        self
+    }
+}
+
+impl RenderOnce for AvatarAvailabilityIndicator {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let avatar_size = self.avatar_size.unwrap_or_else(|| cx.rem_size());
+
+        // HACK: non-integer sizes result in oval indicators.
+        let indicator_size = (avatar_size * 0.4).round();
+
+        div()
+            .absolute()
+            .bottom_0()
+            .right_0()
+            .size(indicator_size)
+            .rounded(indicator_size)
+            .bg(match self.availability {
+                Availability::Free => cx.theme().status().created,
+                Availability::Busy => cx.theme().status().deleted,
+            })
+    }
+}

crates/ui/src/components/stories/avatar.rs 🔗

@@ -1,8 +1,8 @@
 use gpui::Render;
 use story::Story;
 
-use crate::prelude::*;
 use crate::Avatar;
+use crate::{prelude::*, Availability, AvatarAvailabilityIndicator};
 
 pub struct AvatarStory;
 
@@ -19,11 +19,11 @@ impl Render for AvatarStory {
             ))
             .child(
                 Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                    .availability_indicator(true),
+                    .indicator(AvatarAvailabilityIndicator::new(Availability::Free)),
             )
             .child(
                 Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                    .availability_indicator(false),
+                    .indicator(AvatarAvailabilityIndicator::new(Availability::Busy)),
             )
     }
 }