From 66a9b3e9dfa494c528e7a1e11f12392a6baffb68 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 2 Jul 2025 18:58:06 +0800 Subject: [PATCH] avatar: Add Avatar element. (#1028) image image --- assets/icons/building-2.svg | 16 +++ assets/icons/user.svg | 16 +++ crates/story/src/avatar_story.rs | 154 ++++++++++++++++++++++++ crates/story/src/lib.rs | 2 + crates/story/src/main.rs | 1 + crates/ui/src/avatar/avatar.rs | 172 +++++++++++++++++++++++++++ crates/ui/src/avatar/avatar_group.rs | 108 +++++++++++++++++ crates/ui/src/avatar/mod.rs | 39 ++++++ crates/ui/src/icon.rs | 4 + crates/ui/src/lib.rs | 1 + 10 files changed, 513 insertions(+) create mode 100644 assets/icons/building-2.svg create mode 100644 assets/icons/user.svg create mode 100644 crates/story/src/avatar_story.rs create mode 100644 crates/ui/src/avatar/avatar.rs create mode 100644 crates/ui/src/avatar/avatar_group.rs create mode 100644 crates/ui/src/avatar/mod.rs diff --git a/assets/icons/building-2.svg b/assets/icons/building-2.svg new file mode 100644 index 00000000..a5b21833 --- /dev/null +++ b/assets/icons/building-2.svg @@ -0,0 +1,16 @@ + diff --git a/assets/icons/user.svg b/assets/icons/user.svg new file mode 100644 index 00000000..aabc54fb --- /dev/null +++ b/assets/icons/user.svg @@ -0,0 +1,16 @@ + diff --git a/crates/story/src/avatar_story.rs b/crates/story/src/avatar_story.rs new file mode 100644 index 00000000..0559d592 --- /dev/null +++ b/crates/story/src/avatar_story.rs @@ -0,0 +1,154 @@ +use gpui::{ + px, App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, + Render, Styled, Window, +}; +use gpui_component::{ + avatar::{Avatar, AvatarGroup}, + dock::PanelControl, + v_flex, ActiveTheme, IconName, Sizable as _, StyledExt, +}; + +use crate::section; + +pub struct AvatarStory { + focus_handle: gpui::FocusHandle, +} + +impl AvatarStory { + fn new(_: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl super::Story for AvatarStory { + fn title() -> &'static str { + "Avatar" + } + + fn description() -> &'static str { + "Avatar is an image that represents a user or organization." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } + + fn zoomable() -> Option { + None + } +} + +impl Focusable for AvatarStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AvatarStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_4() + .child( + section("Avatar with image") + .max_w_md() + .child( + Avatar::new() + .name("Jason lee") + .src("https://i.pravatar.cc/200?u=a") + .with_size(px(100.)), + ) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=b").large()) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=c")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=d").small()) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=e").xsmall()), + ) + .child( + section("Avatar with text") + .max_w_md() + .child(Avatar::new().name("Jason Lee").large()) + .child(Avatar::new().name("Floyd Wang")) + .child(Avatar::new().name("xda").small()) + .child(Avatar::new().name("ihavecoke").xsmall()), + ) + .child( + section("Placeholder") + .max_w_md() + .child(Avatar::new().large()) + .child(Avatar::new()) + .child(Avatar::new().small()) + .child(Avatar::new().xsmall()) + .child(Avatar::new().placeholder(IconName::Building2)), + ) + .child( + section("Avatar Group") + .v_flex() + .max_w_md() + .child( + AvatarGroup::new() + .child(Avatar::new().src("https://i.pravatar.cc/200?u=a")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=b")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=c")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=d")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=e")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=f")), + ) + .child( + AvatarGroup::new() + .small() + .limit(5) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=a")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=b")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=c")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=d")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=e")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=f")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=g")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=h")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=i")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=j")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=k")), + ) + .child( + AvatarGroup::new() + .xsmall() + .limit(6) + .ellipsis() + .child(Avatar::new().src("https://i.pravatar.cc/200?u=a")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=b")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=c")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=d")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=e")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=f")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=g")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=h")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=i")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=j")) + .child(Avatar::new().src("https://i.pravatar.cc/200?u=k")), + ), + ) + .child( + section("Custom rounded").child( + Avatar::new() + .src("https://i.pravatar.cc/200?u=a") + .with_size(px(100.)) + .rounded(px(20.)), + ), + ) + .child( + section("Custom Style").child( + Avatar::new() + .src("https://i.pravatar.cc/200?u=c") + .with_size(px(100.)) + .border_3() + .border_color(cx.theme().foreground) + .shadow_sm(), + ), + ) + } +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 4af91e85..8cdc4380 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -1,6 +1,7 @@ mod accordion_story; mod alert_story; mod assets; +mod avatar_story; mod badge_story; mod button_story; mod calendar_story; @@ -52,6 +53,7 @@ use gpui::{ pub use accordion_story::AccordionStory; pub use alert_story::AlertStory; +pub use avatar_story::AvatarStory; pub use badge_story::BadgeStory; pub use button_story::ButtonStory; pub use calendar_story::CalendarStory; diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index ac2e11ba..4cbf3b86 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -39,6 +39,7 @@ impl Gallery { vec![ StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/ui/src/avatar/avatar.rs b/crates/ui/src/avatar/avatar.rs new file mode 100644 index 00000000..66578feb --- /dev/null +++ b/crates/ui/src/avatar/avatar.rs @@ -0,0 +1,172 @@ +use std::sync::LazyLock; + +use gpui::{ + div, img, prelude::FluentBuilder, App, Div, Hsla, ImageSource, InteractiveElement, + Interactivity, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, + Styled, Window, +}; + +use crate::{ + avatar::{avatar_size, AvatarSized as _}, + ActiveTheme, Icon, IconName, Sizable, Size, StyledExt, +}; + +static AVATAR_COLORS: LazyLock<[Hsla; 17]> = LazyLock::new(|| { + [ + crate::red_500(), + crate::orange_500(), + crate::amber_500(), + crate::yellow_500(), + crate::lime_500(), + crate::green_500(), + crate::emerald_500(), + crate::teal_500(), + crate::cyan_500(), + crate::sky_500(), + crate::blue_500(), + crate::indigo_500(), + crate::violet_500(), + crate::fuchsia_500(), + crate::purple_500(), + crate::pink_500(), + crate::rose_500(), + ] +}); + +/// User avatar element. +/// +/// We can use [`Sizable`] trait to set the size of the avatar (see also: [`avatar_size`] about the size in pixels). +#[derive(IntoElement)] +pub struct Avatar { + base: Div, + style: StyleRefinement, + src: Option, + name: Option, + short_name: SharedString, + placeholder: Icon, + size: Size, +} + +impl Avatar { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + src: None, + name: None, + short_name: SharedString::default(), + placeholder: Icon::new(IconName::User), + size: Size::Medium, + } + } + + /// Set to use image source for the avatar. + pub fn src(mut self, source: impl Into) -> Self { + self.src = Some(source.into()); + self + } + + /// Set name of the avatar user, if `src` is none, will use this name as placeholder. + pub fn name(mut self, name: impl Into) -> Self { + let name: SharedString = name.into(); + let short: SharedString = extract_text_initials(&name).into(); + + self.name = Some(name); + self.short_name = short; + self + } + + /// Set placeholder icon, default: [`IconName::User`] + pub fn placeholder(mut self, icon: impl Into) -> Self { + self.placeholder = icon.into(); + self + } +} +impl Sizable for Avatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} +impl Styled for Avatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for Avatar { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl RenderOnce for Avatar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let corner_radii = self.style.corner_radii.clone(); + let mut inner_style = StyleRefinement::default(); + inner_style.corner_radii = corner_radii; + + const BG_OPACITY: f32 = 0.2; + + self.base + .avatar_size(self.size) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .rounded_full() + .overflow_hidden() + .bg(cx.theme().secondary) + .text_color(cx.theme().background) + .border_1() + .border_color(cx.theme().background) + .when(self.name.is_none() && self.src.is_none(), |this| { + this.text_size(avatar_size(self.size) * 0.6) + .child(self.placeholder) + }) + .map(|this| match self.src { + None => this.when(self.name.is_some(), |this| { + let color_ix = gpui::hash(&self.short_name) % AVATAR_COLORS.len() as u64; + let color = AVATAR_COLORS[color_ix as usize]; + + this.bg(color.opacity(BG_OPACITY)) + .text_color(color) + .child(div().avatar_text_size(self.size).child(self.short_name)) + }), + Some(src) => this.child( + img(src) + .avatar_size(self.size) + .rounded_full() + .refine_style(&inner_style), + ), + }) + .refine_style(&self.style) + } +} + +fn extract_text_initials(text: &str) -> String { + let mut result = text + .split(" ") + .flat_map(|word| word.chars().next().map(|c| c.to_string())) + .take(2) + .collect::>() + .join(""); + + if result.len() == 1 { + result = text.chars().take(2).collect::(); + } + + result.to_uppercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_avatar_text_initials() { + assert_eq!(extract_text_initials(&"Jason Lee"), "JL".to_string()); + assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string()); + assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string()); + } +} diff --git a/crates/ui/src/avatar/avatar_group.rs b/crates/ui/src/avatar/avatar_group.rs new file mode 100644 index 00000000..59db9ab7 --- /dev/null +++ b/crates/ui/src/avatar/avatar_group.rs @@ -0,0 +1,108 @@ +use gpui::{ + div, prelude::FluentBuilder as _, Div, InteractiveElement, Interactivity, IntoElement, + ParentElement as _, RenderOnce, StyleRefinement, Styled, +}; + +use crate::{avatar::Avatar, ActiveTheme, Sizable, Size, StyledExt as _}; + +/// A grouped avatars to display in a compact layout. +#[derive(IntoElement)] +pub struct AvatarGroup { + base: Div, + style: StyleRefinement, + avatars: Vec, + size: Size, + limit: usize, + ellipsis: bool, +} + +impl AvatarGroup { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + avatars: Vec::new(), + size: Size::default(), + limit: 3, + ellipsis: false, + } + } + + /// Add a child avatar to the group. + pub fn child(mut self, avatar: Avatar) -> Self { + self.avatars.push(avatar); + self + } + + /// Add multiple child avatars to the group. + pub fn children(mut self, avatars: impl IntoIterator) -> Self { + self.avatars.extend(avatars); + self + } + + /// Set the maximum number of avatars to display before showing a "more" avatar. + pub fn limit(mut self, limit: usize) -> Self { + self.limit = limit; + self + } + + /// Set whether to show an ellipsis when the limit is reached, default: false + pub fn ellipsis(mut self) -> Self { + self.ellipsis = true; + self + } +} + +impl Sizable for AvatarGroup { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for AvatarGroup { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for AvatarGroup { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl RenderOnce for AvatarGroup { + fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let item_ml = -super::avatar_size(self.size) * 0.3; + let avatars_len = self.avatars.len(); + + self.base + .h_flex() + .flex_row_reverse() + .refine_style(&self.style) + .children(if self.ellipsis && avatars_len > self.limit { + Some( + Avatar::new() + .name("⋯") + .bg(cx.theme().secondary) + .text_color(cx.theme().muted_foreground) + .with_size(self.size) + .ml_1(), + ) + } else { + None + }) + .children( + self.avatars + .into_iter() + .take(self.limit) + .enumerate() + .rev() + .map(|(ix, item)| { + item.with_size(self.size) + .when(ix > 0, |this| this.ml(item_ml)) + }), + ) + } +} diff --git a/crates/ui/src/avatar/mod.rs b/crates/ui/src/avatar/mod.rs new file mode 100644 index 00000000..2db67785 --- /dev/null +++ b/crates/ui/src/avatar/mod.rs @@ -0,0 +1,39 @@ +mod avatar; +mod avatar_group; + +pub use avatar::*; +pub use avatar_group::*; + +use crate::{Icon, Size, StyledExt as _}; +use gpui::{px, rems, Div, Img, IntoElement, Pixels, Styled}; + +/// Returns the size of the avatar based on the given [`Size`]. +pub(super) fn avatar_size(size: Size) -> Pixels { + match size { + Size::Large => px(80.), + Size::Medium => px(48.), + Size::Small => px(24.), + Size::XSmall => px(20.), + Size::Size(size) => size, + } +} + +/// Extension for add `avatar_size` method to `IntoElement` to apply avatar size to element. +pub(super) trait AvatarSized: IntoElement + Styled { + fn avatar_size(self, size: Size) -> Self { + self.size(avatar_size(size)) + } + + fn avatar_text_size(self, size: Size) -> Self { + match size { + Size::Large => self.text_3xl().font_semibold(), + Size::Medium => self.text_sm(), + Size::Small => self.text_xs(), + Size::XSmall => self.text_size(rems(0.65)), + Size::Size(size) => self.size(size * 0.5), + } + } +} +impl AvatarSized for Div {} +impl AvatarSized for Icon {} +impl AvatarSized for Img {} diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 06b02674..e4e0b499 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -16,6 +16,7 @@ pub enum IconName { Bell, BookOpen, Bot, + Building2, Calendar, ChartPie, Check, @@ -77,6 +78,7 @@ pub enum IconName { ThumbsDown, ThumbsUp, TriangleAlert, + User, WindowClose, WindowMaximize, WindowMinimize, @@ -95,6 +97,7 @@ impl IconName { Self::Bell => "icons/bell.svg", Self::BookOpen => "icons/book-open.svg", Self::Bot => "icons/bot.svg", + Self::Building2 => "icons/building-2.svg", Self::Calendar => "icons/calendar.svg", Self::ChartPie => "icons/chart-pie.svg", Self::Check => "icons/check.svg", @@ -156,6 +159,7 @@ impl IconName { Self::ThumbsDown => "icons/thumbs-down.svg", Self::ThumbsUp => "icons/thumbs-up.svg", Self::TriangleAlert => "icons/triangle-alert.svg", + Self::User => "icons/user.svg", Self::WindowClose => "icons/window-close.svg", Self::WindowMaximize => "icons/window-maximize.svg", Self::WindowMinimize => "icons/window-minimize.svg", diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 69d3d404..84e2dcd6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -19,6 +19,7 @@ pub(crate) mod actions; pub mod accordion; pub mod alert; pub mod animation; +pub mod avatar; pub mod badge; pub mod breadcrumb; pub mod button;