From ca4a8b22261a54e8e6e5db3c2e6e7fb5f7dbcb45 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 16 Jan 2024 14:05:05 -0500 Subject: [PATCH] Rework `Avatar` indicator to be more general-purpose (#4073) 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 --- crates/collab_ui/src/collab_panel.rs | 84 ++++++----- crates/ui/src/components/avatar.rs | 138 +----------------- crates/ui/src/components/avatar/avatar.rs | 122 ++++++++++++++++ .../avatar/avatar_availability_indicator.rs | 48 ++++++ crates/ui/src/components/stories/avatar.rs | 6 +- 5 files changed, 222 insertions(+), 176 deletions(-) create mode 100644 crates/ui/src/components/avatar/avatar.rs create mode 100644 crates/ui/src/components/avatar/avatar_availability_indicator.rs diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 9cb447153c1bc39b9d291a2e17020cae788b43ca..ec94838386029b69c9feeea4f1879ea792e56bc7 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/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::(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()) diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index a97adb73b7d88ae0dfb1c60c25eff3942c5ad52d..b200828ce6cd5a4ac190faf65f8417e08844cf43 100644 --- a/crates/ui/src/components/avatar.rs +++ b/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, - border_color: Option, - is_available: Option, -} - -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) -> 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) -> Self { - self.border_color = Some(color.into()); - self - } - - pub fn availability_indicator(mut self, is_available: impl Into>) -> 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>) -> Self { - self.size = size.into(); - self - } -} +pub use avatar::*; +pub use avatar_availability_indicator::*; diff --git a/crates/ui/src/components/avatar/avatar.rs b/crates/ui/src/components/avatar/avatar.rs new file mode 100644 index 0000000000000000000000000000000000000000..22ba255a60f12742e173f016886ce25b404d2c0f --- /dev/null +++ b/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, + border_color: Option, + indicator: Option, +} + +impl Avatar { + pub fn new(src: impl Into) -> 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) -> 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>) -> Self { + self.size = size.into(); + self + } + + pub fn indicator(mut self, indicator: impl Into>) -> 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)), + ) + } +} diff --git a/crates/ui/src/components/avatar/avatar_availability_indicator.rs b/crates/ui/src/components/avatar/avatar_availability_indicator.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a033cd3959c5d63af5960ba839fb37950fbec57 --- /dev/null +++ b/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, +} + +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>) -> 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, + }) + } +} diff --git a/crates/ui/src/components/stories/avatar.rs b/crates/ui/src/components/stories/avatar.rs index e447486d69ca4a22a10e3d8975546b4ef661cf0d..c7aae8a73cde4098dca874754c40dd994a1da934 100644 --- a/crates/ui/src/components/stories/avatar.rs +++ b/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)), ) } }