use gpui_kit::base::StyledExt as _;
use gpui_kit::{
AnyElement, App, ElementId, ImageSource, InteractiveElement as _, IntoElement,
ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, TestSupportExt as _,
Window, base, div, prelude::FluentBuilder as _,
};
use crate::{ActiveTheme as _, Icon};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AvatarSize {
Sm,
#[default]
Default,
Lg,
}
#[derive(IntoElement)]
pub struct Avatar {
id: ElementId,
style: StyleRefinement,
size: AvatarSize,
src: Option<ImageSource>,
text: Option<SharedString>,
icon: Option<Icon>,
ring: bool,
}
impl Avatar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
size: AvatarSize::Default,
src: None,
text: None,
icon: None,
ring: false,
}
}
pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
self.src = Some(source.into());
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.text = Some(initials(&name.into()));
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
fn text(mut self, text: SharedString) -> Self {
self.text = Some(text);
self
}
fn ring(mut self) -> Self {
self.ring = true;
self
}
}
fn initials(name: &str) -> SharedString {
let mut words = name.split_whitespace();
let first = words.next().and_then(|word| word.chars().next());
let last = words.last().and_then(|word| word.chars().next());
first
.into_iter()
.chain(last)
.flat_map(char::to_uppercase)
.collect::<String>()
.into()
}
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Avatar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let metrics = &theme.metrics;
let (diameter, text_size) = match self.size {
AvatarSize::Sm => (metrics.avatar_sm, metrics.avatar_initials_sm),
AvatarSize::Default => (metrics.avatar_md, metrics.avatar_initials_md),
AvatarSize::Lg => (metrics.avatar_lg, metrics.avatar_initials_lg),
};
let (fill, ink, ring, ring_width) = (
theme.selected,
theme.foreground(),
theme.background(),
metrics.avatar_group_ring,
);
let content: Option<AnyElement> = match (self.text, self.icon) {
(Some(text), _) => Some(text.into_any_element()),
(None, Some(icon)) => Some(icon.size(diameter / 2.).into_any_element()),
(None, None) => None,
};
let fallback = base::AvatarFallback::new()
.size_full()
.rounded_full()
.flex()
.items_center()
.justify_center()
.bg(fill)
.text_color(ink)
.text_size(text_size)
.font_weight(gpui_kit::FontWeight::MEDIUM)
.relative()
.children(content)
.children(self.src.map(|src| {
base::AvatarImage::new(src)
.absolute()
.inset_0()
.size_full()
.rounded_full()
}));
let avatar = base::Avatar::new()
.size_full()
.rounded_full()
.fallback(fallback);
div()
.id(self.id)
.test_support()
.flex_none()
.size(diameter)
.rounded_full()
.when(self.ring, |this| this.border(ring_width).border_color(ring))
.child(avatar)
.refine_style(&self.style)
}
}
#[derive(IntoElement)]
pub struct AvatarGroup {
id: ElementId,
style: StyleRefinement,
size: AvatarSize,
avatars: Vec<Avatar>,
limit: Option<usize>,
}
impl AvatarGroup {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
size: AvatarSize::Default,
avatars: Vec::new(),
limit: None,
}
}
pub fn child(mut self, avatar: Avatar) -> Self {
self.avatars.push(avatar);
self
}
pub fn children(mut self, avatars: impl IntoIterator<Item = Avatar>) -> Self {
self.avatars.extend(avatars);
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
pub fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
}
impl Styled for AvatarGroup {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for AvatarGroup {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let overlap = cx.theme().metrics.avatar_group_overlap;
let total = self.avatars.len();
let shown = self.limit.unwrap_or(total).min(total);
let rest = total - shown;
let size = self.size;
let more = (rest > 0).then(|| {
Avatar::new(ElementId::NamedChild(
std::sync::Arc::new(self.id.clone()),
"more".into(),
))
.text(format!("+{rest}").into())
});
let avatars = self
.avatars
.into_iter()
.take(shown)
.chain(more)
.enumerate()
.map(move |(index, avatar)| {
let avatar = avatar.size(size).ring();
if index == 0 {
avatar
} else {
avatar.ml(-overlap)
}
});
base::h_flex()
.id(self.id)
.test_support()
.children(avatars)
.refine_style(&self.style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initials_take_the_first_and_last_words() {
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials(" grace brewster hopper "), "GH");
assert_eq!(initials("plato"), "P");
assert_eq!(initials(""), "");
}
}