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
This commit is contained in:
Marshall Bowers 2024-01-16 14:05:05 -05:00 committed by GitHub
parent d00067cd86
commit ca4a8b2226
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 222 additions and 176 deletions

View file

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

View file

@ -1,135 +1,5 @@
use crate::prelude::*; mod avatar;
use gpui::{img, Hsla, ImageSource, Img, IntoElement, Styled}; mod avatar_availability_indicator;
/// The shape of an [`Avatar`]. pub use avatar::*;
#[derive(Debug, Default, PartialEq, Clone)] pub use avatar_availability_indicator::*;
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
}
}

View file

@ -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)),
)
}
}

View file

@ -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,
})
}
}

View file

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