Skip to main content

gpui_component/avatar/
avatar.rs

1use gpui::{
2    App, Hsla, ImageSource, InteractiveElement, Interactivity, IntoElement, ParentElement as _,
3    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder,
4};
5use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage, TestTextExt as _};
6
7use crate::{
8    ActiveTheme, Icon, IconName, Sizable, Size, StyledExt, ThemeStyled as _,
9    avatar::{AvatarSized as _, avatar_size},
10    oklch,
11};
12
13/// User avatar element.
14///
15/// We can use [`Sizable`] trait to set the size of the avatar (see also: [`avatar_size`] about the size in pixels).
16#[derive(IntoElement)]
17pub struct Avatar {
18    base: BaseAvatar,
19    style: StyleRefinement,
20    src: Option<ImageSource>,
21    name: Option<SharedString>,
22    short_name: SharedString,
23    placeholder: Icon,
24    size: Size,
25}
26
27impl Avatar {
28    pub fn new() -> Self {
29        Self {
30            base: BaseAvatar::new(),
31            style: StyleRefinement::default(),
32            src: None,
33            name: None,
34            short_name: SharedString::default(),
35            placeholder: Icon::new(IconName::User),
36            size: Size::Medium,
37        }
38    }
39
40    /// Set to use image source for the avatar.
41    pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
42        self.src = Some(source.into());
43        self
44    }
45
46    /// Set name of the avatar user, if `src` is none, will use this name as placeholder.
47    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
48        let name: SharedString = name.into();
49        let short: SharedString = extract_text_initials(&name).into();
50
51        self.name = Some(name);
52        self.short_name = short;
53        self
54    }
55
56    /// Set placeholder icon, default: [`IconName::User`]
57    pub fn placeholder(mut self, icon: impl Into<Icon>) -> Self {
58        self.placeholder = icon.into();
59        self
60    }
61}
62
63impl Sizable for Avatar {
64    fn with_size(mut self, size: impl Into<Size>) -> Self {
65        self.size = size.into();
66        self
67    }
68}
69
70impl Styled for Avatar {
71    fn style(&mut self) -> &mut StyleRefinement {
72        &mut self.style
73    }
74}
75
76impl InteractiveElement for Avatar {
77    fn interactivity(&mut self) -> &mut Interactivity {
78        self.base.interactivity()
79    }
80}
81
82impl RenderOnce for Avatar {
83    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
84        let corner_radii = self.style.corner_radii.clone();
85        let mut inner_style = StyleRefinement::default();
86        inner_style.corner_radii = corner_radii;
87
88        let identity = self
89            .name
90            .is_some()
91            .then(|| IdentityColor::new(&self.short_name, cx));
92
93        // The tinted outline belongs to the initials. An image, or the anonymous
94        // placeholder, keeps the neutral border.
95        let border_color = match (identity, &self.src) {
96            (Some(identity), None) => identity.border,
97            _ => cx.theme().border,
98        };
99
100        let fallback = AvatarFallback::new()
101            .size_full()
102            .flex()
103            .items_center()
104            .justify_center()
105            .rounded_full_style(cx)
106            .overflow_hidden()
107            .when_none(&identity, |this| {
108                this.text_size(avatar_size(self.size) * 0.6)
109                    .child(self.placeholder)
110            })
111            .when_some(identity, |this, identity| {
112                this.bg(identity.background)
113                    .text_color(identity.foreground)
114                    .child(div().avatar_text_size(self.size).child(self.short_name))
115            })
116            .refine_style(&inner_style);
117
118        // The name is the avatar's alternative text, as an `<img alt>` is.
119        let name = self.name.clone();
120        self.base
121            .size(avatar_size(self.size))
122            .flex_shrink_0()
123            .rounded_full_style(cx)
124            .overflow_hidden()
125            .bg(cx.theme().tokens.secondary)
126            .text_color(cx.theme().background)
127            .border_1()
128            .border_color(border_color)
129            .fallback(fallback)
130            .when_some(self.src, |this, src| {
131                this.image(
132                    AvatarImage::new(src)
133                        .size_full()
134                        .rounded_full_style(cx)
135                        .refine_style(&inner_style),
136                )
137            })
138            .refine_style(&self.style)
139            .map(|this| match name {
140                Some(name) => this.test_alt_text(name).into_any_element(),
141                None => this.into_any_element(),
142            })
143    }
144}
145
146/// The colors a name-based fallback draws itself in, picked from the initials so
147/// the same person always gets the same ones.
148///
149/// The ring is 12 evenly spaced OkLCH hues at a fixed lightness and chroma.
150/// Unlike an HSL rotation, which changes perceived brightness as it turns, that
151/// keeps every avatar at one visual weight and its text legible on every hue.
152#[derive(Debug, Clone, Copy, PartialEq)]
153struct IdentityColor {
154    background: Hsla,
155    foreground: Hsla,
156    border: Hsla,
157}
158
159impl IdentityColor {
160    const HUES: u64 = 12;
161    const HUE_STEP: f32 = 360. / Self::HUES as f32;
162
163    fn new(short_name: &SharedString, cx: &App) -> Self {
164        let hue = (gpui::hash(short_name) % Self::HUES) as f32 * Self::HUE_STEP;
165        Self::from_hue(hue, cx.theme().is_dark())
166    }
167
168    fn from_hue(hue: f32, is_dark: bool) -> Self {
169        // Background and foreground hold WCAG AA against each other, and the
170        // border carries the most chroma sRGB has at its lightness for every
171        // hue. Both are pinned by the tests below.
172        let (background, foreground, border) = if is_dark {
173            (
174                oklch(0.30, 0.05, hue),
175                oklch(0.82, 0.11, hue),
176                oklch(0.36, 0.06, hue),
177            )
178        } else {
179            (
180                oklch(0.97, 0.032, hue),
181                oklch(0.50, 0.145, hue),
182                oklch(0.89, 0.05, hue),
183            )
184        };
185
186        Self {
187            background,
188            foreground,
189            border,
190        }
191    }
192}
193
194fn extract_text_initials(text: &str) -> String {
195    let mut result = text
196        .split(" ")
197        .flat_map(|word| word.chars().next().map(|c| c.to_string()))
198        .take(2)
199        .collect::<Vec<String>>()
200        .join("");
201
202    if result.len() == 1 {
203        result = text.chars().take(2).collect::<String>();
204    }
205
206    result.to_uppercase()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use gpui::Rgba;
213
214    /// WCAG 2.1 relative luminance.
215    fn luminance(color: Hsla) -> f32 {
216        let channel = |c: f32| {
217            if c <= 0.03928 {
218                c / 12.92
219            } else {
220                ((c + 0.055) / 1.055).powf(2.4)
221            }
222        };
223
224        let rgb: Rgba = color.into();
225        0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b)
226    }
227
228    fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
229        let (a, b) = (luminance(a), luminance(b));
230        let (lighter, darker) = if a > b { (a, b) } else { (b, a) };
231        (lighter + 0.05) / (darker + 0.05)
232    }
233
234    fn ring(is_dark: bool) -> impl Iterator<Item = (f32, IdentityColor)> {
235        (0..IdentityColor::HUES).map(move |step| {
236            let hue = step as f32 * IdentityColor::HUE_STEP;
237            (hue, IdentityColor::from_hue(hue, is_dark))
238        })
239    }
240
241    #[test]
242    fn identity_colors_stay_legible_on_every_hue() {
243        for is_dark in [false, true] {
244            for (hue, color) in ring(is_dark) {
245                let ratio = contrast_ratio(color.foreground, color.background);
246
247                assert!(
248                    ratio >= 4.5,
249                    "hue {hue} (dark: {is_dark}) has contrast {ratio:.2}, below WCAG AA"
250                );
251            }
252        }
253    }
254
255    /// A color pushed past what sRGB holds at its lightness comes back clamped
256    /// to the gamut edge, which HSL reports as full saturation. The border
257    /// carries the most chroma that clears this on every hue; raising it would
258    /// silently flatten a third of the ring.
259    #[test]
260    fn identity_borders_stay_inside_the_srgb_gamut() {
261        for is_dark in [false, true] {
262            for (hue, color) in ring(is_dark) {
263                assert!(
264                    color.border.s < 1.,
265                    "border at hue {hue} (dark: {is_dark}) is clamped to the sRGB gamut edge"
266                );
267            }
268        }
269    }
270
271    #[test]
272    fn test_avatar_text_initials() {
273        assert_eq!(extract_text_initials(&"Jason Lee"), "JL".to_string());
274        assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string());
275        assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string());
276    }
277
278    #[gpui::test]
279    fn test_avatar_builder(_cx: &mut gpui::TestAppContext) {
280        let avatar = Avatar::new()
281            .name("Jason Lee")
282            .placeholder(Icon::new(IconName::User))
283            .large();
284
285        assert_eq!(avatar.name, Some(SharedString::from("Jason Lee")));
286        assert_eq!(avatar.short_name, SharedString::from("JL"));
287        assert_eq!(avatar.size, Size::Large);
288    }
289}