avatar: Add Avatar element. (#1028)

<img width="1384" alt="image"
src="https://github.com/user-attachments/assets/1e66aa8c-0c87-4d8d-abb7-38e5f616fe3e"
/>
<img width="1384" alt="image"
src="https://github.com/user-attachments/assets/bd32b6e0-aa0f-4d01-af9f-4bfaea1a3718"
/>
This commit is contained in:
Jason Lee 2025-07-02 18:58:06 +08:00 committed by GitHub
parent a28ad0b1f1
commit 66a9b3e9df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 513 additions and 0 deletions

View file

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-building2-icon lucide-building-2"
><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" /><path
d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"
/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" /><path
d="M10 6h4"
/><path d="M10 10h4" /><path d="M10 14h4" /><path d="M10 18h4" /></svg>

After

Width:  |  Height:  |  Size: 550 B

16
assets/icons/user.svg Normal file
View file

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-user-icon lucide-user"
><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/></svg>

After

Width:  |  Height:  |  Size: 386 B

View file

@ -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 {
Self {
focus_handle: cx.focus_handle(),
}
}
pub fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
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<impl Render + Focusable> {
Self::view(window, cx)
}
fn zoomable() -> Option<PanelControl> {
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<Self>) -> 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(),
),
)
}
}

View file

@ -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;

View file

@ -39,6 +39,7 @@ impl Gallery {
vec![
StoryContainer::panel::<AccordionStory>(window, cx),
StoryContainer::panel::<AlertStory>(window, cx),
StoryContainer::panel::<AvatarStory>(window, cx),
StoryContainer::panel::<BadgeStory>(window, cx),
StoryContainer::panel::<ButtonStory>(window, cx),
StoryContainer::panel::<CalendarStory>(window, cx),

View file

@ -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<ImageSource>,
name: Option<SharedString>,
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<ImageSource>) -> 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<SharedString>) -> 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<Icon>) -> Self {
self.placeholder = icon.into();
self
}
}
impl Sizable for Avatar {
fn with_size(mut self, size: impl Into<Size>) -> 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::<Vec<String>>()
.join("");
if result.len() == 1 {
result = text.chars().take(2).collect::<String>();
}
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());
}
}

View file

@ -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<Avatar>,
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<Item = Avatar>) -> 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<Size>) -> 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))
}),
)
}
}

View file

@ -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 {}

View file

@ -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",

View file

@ -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;