Skip to main content

gpui_cn/
avatar.rs

1use gpui_kit::base::StyledExt as _;
2use gpui_kit::{
3    AnyElement, App, ElementId, ImageSource, InteractiveElement as _, IntoElement,
4    ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, TestSupportExt as _,
5    Window, base, div, prelude::FluentBuilder as _,
6};
7
8use crate::{ActiveTheme as _, Icon};
9
10/// The diameter of an [`Avatar`].
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum AvatarSize {
13    /// 24px, for dense rows and chips.
14    Sm,
15    /// 32px, shadcn's default.
16    #[default]
17    Default,
18    /// 40px, for headers and cards.
19    Lg,
20}
21
22/// A shadcn-style avatar: a circle that shows an image, or the initials
23/// of a name, or an icon when there is no image.
24///
25/// The image paints over the fallback, so the initials or the icon show
26/// while the image loads and stay when it fails, as Radix's avatar does.
27/// The icon is half the circle across.
28/// The fallback fill is the theme's `selected` step with `foreground`
29/// text. shadcn's `bg-muted` is the page's own hover step here, too faint
30/// to read as a circle.
31///
32/// ```
33/// use gpui_cn::{Avatar, AvatarSize};
34///
35/// let _ = Avatar::new("ada").name("Ada Lovelace").size(AvatarSize::Lg);
36/// ```
37#[derive(IntoElement)]
38pub struct Avatar {
39    id: ElementId,
40    style: StyleRefinement,
41    size: AvatarSize,
42    src: Option<ImageSource>,
43    text: Option<SharedString>,
44    icon: Option<Icon>,
45    ring: bool,
46}
47
48impl Avatar {
49    /// An avatar with a stable id.
50    pub fn new(id: impl Into<ElementId>) -> Self {
51        Self {
52            id: id.into(),
53            style: StyleRefinement::default(),
54            size: AvatarSize::Default,
55            src: None,
56            text: None,
57            icon: None,
58            ring: false,
59        }
60    }
61
62    /// The image to show.
63    pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
64        self.src = Some(source.into());
65        self
66    }
67
68    /// The name whose initials show when there is no image.
69    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
70        self.text = Some(initials(&name.into()));
71        self
72    }
73
74    /// The icon that shows when there is no image and no name.
75    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
76        self.icon = Some(icon.into());
77        self
78    }
79
80    /// The diameter.
81    pub fn size(mut self, size: AvatarSize) -> Self {
82        self.size = size;
83        self
84    }
85
86    /// Text to show as it is, such as the `+N` of a group.
87    fn text(mut self, text: SharedString) -> Self {
88        self.text = Some(text);
89        self
90    }
91
92    /// Paints a ring in the window background around the circle, so it
93    /// parts from an avatar it overlaps. [`AvatarGroup`] sets it.
94    fn ring(mut self) -> Self {
95        self.ring = true;
96        self
97    }
98}
99
100/// The initials of `name`: the first letter of its first and last words,
101/// in capitals.
102fn initials(name: &str) -> SharedString {
103    let mut words = name.split_whitespace();
104    let first = words.next().and_then(|word| word.chars().next());
105    let last = words.last().and_then(|word| word.chars().next());
106    first
107        .into_iter()
108        .chain(last)
109        .flat_map(char::to_uppercase)
110        .collect::<String>()
111        .into()
112}
113
114impl Styled for Avatar {
115    fn style(&mut self) -> &mut StyleRefinement {
116        &mut self.style
117    }
118}
119
120impl RenderOnce for Avatar {
121    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
122        let theme = cx.theme();
123        let metrics = &theme.metrics;
124        let (diameter, text_size) = match self.size {
125            AvatarSize::Sm => (metrics.avatar_sm, metrics.avatar_initials_sm),
126            AvatarSize::Default => (metrics.avatar_md, metrics.avatar_initials_md),
127            AvatarSize::Lg => (metrics.avatar_lg, metrics.avatar_initials_lg),
128        };
129        let (fill, ink, ring, ring_width) = (
130            theme.selected,
131            theme.foreground(),
132            theme.background(),
133            metrics.avatar_group_ring,
134        );
135
136        let content: Option<AnyElement> = match (self.text, self.icon) {
137            (Some(text), _) => Some(text.into_any_element()),
138            (None, Some(icon)) => Some(icon.size(diameter / 2.).into_any_element()),
139            (None, None) => None,
140        };
141        let fallback = base::AvatarFallback::new()
142            .size_full()
143            .rounded_full()
144            .flex()
145            .items_center()
146            .justify_center()
147            .bg(fill)
148            .text_color(ink)
149            .text_size(text_size)
150            .font_weight(gpui_kit::FontWeight::MEDIUM)
151            .relative()
152            .children(content)
153            .children(self.src.map(|src| {
154                base::AvatarImage::new(src)
155                    .absolute()
156                    .inset_0()
157                    .size_full()
158                    .rounded_full()
159            }));
160        let avatar = base::Avatar::new()
161            .size_full()
162            .rounded_full()
163            .fallback(fallback);
164
165        div()
166            .id(self.id)
167            .test_support()
168            .flex_none()
169            .size(diameter)
170            .rounded_full()
171            .when(self.ring, |this| this.border(ring_width).border_color(ring))
172            .child(avatar)
173            .refine_style(&self.style)
174    }
175}
176
177/// A row of overlapping avatars, shadcn's `*:ring-2 -space-x-2`, with a
178/// `+N` avatar for the ones past its limit.
179///
180/// ```
181/// use gpui_cn::{Avatar, AvatarGroup};
182///
183/// let _ = AvatarGroup::new("team")
184///     .child(Avatar::new("ada").name("Ada Lovelace"))
185///     .child(Avatar::new("alan").name("Alan Turing"))
186///     .limit(3);
187/// ```
188#[derive(IntoElement)]
189pub struct AvatarGroup {
190    id: ElementId,
191    style: StyleRefinement,
192    size: AvatarSize,
193    avatars: Vec<Avatar>,
194    limit: Option<usize>,
195}
196
197impl AvatarGroup {
198    /// A group with a stable id.
199    pub fn new(id: impl Into<ElementId>) -> Self {
200        Self {
201            id: id.into(),
202            style: StyleRefinement::default(),
203            size: AvatarSize::Default,
204            avatars: Vec::new(),
205            limit: None,
206        }
207    }
208
209    /// Adds an avatar.
210    pub fn child(mut self, avatar: Avatar) -> Self {
211        self.avatars.push(avatar);
212        self
213    }
214
215    /// Adds avatars.
216    pub fn children(mut self, avatars: impl IntoIterator<Item = Avatar>) -> Self {
217        self.avatars.extend(avatars);
218        self
219    }
220
221    /// Shows at most `limit` avatars and a `+N` avatar for the rest.
222    pub fn limit(mut self, limit: usize) -> Self {
223        self.limit = Some(limit);
224        self
225    }
226
227    /// The diameter of every avatar in the group.
228    pub fn size(mut self, size: AvatarSize) -> Self {
229        self.size = size;
230        self
231    }
232}
233
234impl Styled for AvatarGroup {
235    fn style(&mut self) -> &mut StyleRefinement {
236        &mut self.style
237    }
238}
239
240impl RenderOnce for AvatarGroup {
241    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
242        let overlap = cx.theme().metrics.avatar_group_overlap;
243        let total = self.avatars.len();
244        let shown = self.limit.unwrap_or(total).min(total);
245        let rest = total - shown;
246        let size = self.size;
247        let more = (rest > 0).then(|| {
248            Avatar::new(ElementId::NamedChild(
249                std::sync::Arc::new(self.id.clone()),
250                "more".into(),
251            ))
252            .text(format!("+{rest}").into())
253        });
254        let avatars = self
255            .avatars
256            .into_iter()
257            .take(shown)
258            .chain(more)
259            .enumerate()
260            .map(move |(index, avatar)| {
261                let avatar = avatar.size(size).ring();
262                if index == 0 {
263                    avatar
264                } else {
265                    avatar.ml(-overlap)
266                }
267            });
268        base::h_flex()
269            .id(self.id)
270            .test_support()
271            .children(avatars)
272            .refine_style(&self.style)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn initials_take_the_first_and_last_words() {
282        assert_eq!(initials("Ada Lovelace"), "AL");
283        assert_eq!(initials("  grace brewster hopper "), "GH");
284        assert_eq!(initials("plato"), "P");
285        assert_eq!(initials(""), "");
286    }
287}