Skip to main content

gpui_kit/display/
avatar.rs

1//! A small identity mark.
2
3use gpui::{
4    App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
5    prelude::FluentBuilder, px,
6};
7use gpui_kit_semantics::{NodeSpec, Role, Semantic};
8use gpui_kit_theme::ActiveTheme;
9
10use crate::foundation::Ident;
11
12/// A circular mark for a person or a workspace.
13///
14/// With no image it falls back to initials, and with nothing to take initials
15/// from it stays an empty circle rather than inventing a letter.
16#[derive(Debug, IntoElement)]
17pub struct Avatar {
18    ident: Option<Ident>,
19    name: SharedString,
20    image: Option<SharedString>,
21    size: f32,
22}
23
24impl Avatar {
25    pub fn new(name: impl Into<SharedString>) -> Self {
26        Self {
27            ident: None,
28            name: name.into(),
29            image: None,
30            size: 28.0,
31        }
32    }
33
34    /// Gives the avatar a semantic identity, for a test that has to find it.
35    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
36        self.ident = Some(ident.into());
37        self
38    }
39
40    /// A resource path or URI the asset source can resolve.
41    pub fn image(mut self, image: impl Into<SharedString>) -> Self {
42        self.image = Some(image.into());
43        self
44    }
45
46    pub fn size(mut self, size: f32) -> Self {
47        self.size = size;
48        self
49    }
50
51    /// At most two letters, taken from the first two words.
52    fn initials(&self) -> SharedString {
53        let letters: String = self
54            .name
55            .split_whitespace()
56            .filter_map(|word| word.chars().next())
57            .take(2)
58            .flat_map(|letter| letter.to_uppercase())
59            .collect();
60        SharedString::from(letters)
61    }
62}
63
64impl RenderOnce for Avatar {
65    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
66        let theme = cx.theme().clone();
67        let initials = self.initials();
68        let spec = self
69            .ident
70            .as_ref()
71            .map(|ident| NodeSpec::new(ident.semantic_id(), Role::Image).text(self.name.clone()));
72
73        let element = div()
74            .size(px(self.size))
75            .flex_none()
76            .flex()
77            .items_center()
78            .justify_center()
79            .overflow_hidden()
80            .rounded_full()
81            .bg(theme.colors.raised)
82            .border(px(theme.borders.hairline))
83            .border_color(theme.colors.hairline)
84            .text_size(px(self.size * 0.36))
85            .text_color(theme.colors.text_muted)
86            .when_some(self.image.clone(), |element, source| {
87                element.child(gpui::img(source).size(px(self.size)))
88            })
89            .when(self.image.is_none() && !initials.is_empty(), |element| {
90                element.child(initials.clone())
91            });
92        match spec {
93            Some(spec) => element.semantic_in(cx, spec).into_any_element(),
94            None => element.into_any_element(),
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn initials_take_at_most_two_words() {
105        assert_eq!(Avatar::new("Ada Lovelace King").initials().as_ref(), "AL");
106        assert_eq!(Avatar::new("ada").initials().as_ref(), "A");
107    }
108
109    #[test]
110    fn a_nameless_avatar_invents_no_letter() {
111        assert_eq!(Avatar::new("   ").initials().as_ref(), "");
112    }
113}