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};
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        self.base
119            .size(avatar_size(self.size))
120            .flex_shrink_0()
121            .rounded_full_style(cx)
122            .overflow_hidden()
123            .bg(cx.theme().tokens.secondary)
124            .text_color(cx.theme().background)
125            .border_1()
126            .border_color(border_color)
127            .fallback(fallback)
128            .when_some(self.src, |this, src| {
129                this.image(
130                    AvatarImage::new(src)
131                        .size_full()
132                        .rounded_full_style(cx)
133                        .refine_style(&inner_style),
134                )
135            })
136            .refine_style(&self.style)
137    }
138}
139
140/// The colors a name-based fallback draws itself in, picked from the initials so
141/// the same person always gets the same ones.
142///
143/// The ring is 12 evenly spaced OkLCH hues at a fixed lightness and chroma.
144/// Unlike an HSL rotation, which changes perceived brightness as it turns, that
145/// keeps every avatar at one visual weight and its text legible on every hue.
146#[derive(Debug, Clone, Copy, PartialEq)]
147struct IdentityColor {
148    background: Hsla,
149    foreground: Hsla,
150    border: Hsla,
151}
152
153impl IdentityColor {
154    const HUES: u64 = 12;
155    const HUE_STEP: f32 = 360. / Self::HUES as f32;
156
157    fn new(short_name: &SharedString, cx: &App) -> Self {
158        let hue = (gpui::hash(short_name) % Self::HUES) as f32 * Self::HUE_STEP;
159        Self::from_hue(hue, cx.theme().is_dark())
160    }
161
162    fn from_hue(hue: f32, is_dark: bool) -> Self {
163        // Background and foreground hold WCAG AA against each other, and the
164        // border carries the most chroma sRGB has at its lightness for every
165        // hue. Both are pinned by the tests below.
166        let (background, foreground, border) = if is_dark {
167            (
168                oklch(0.30, 0.05, hue),
169                oklch(0.82, 0.11, hue),
170                oklch(0.36, 0.06, hue),
171            )
172        } else {
173            (
174                oklch(0.97, 0.032, hue),
175                oklch(0.50, 0.145, hue),
176                oklch(0.89, 0.05, hue),
177            )
178        };
179
180        Self {
181            background,
182            foreground,
183            border,
184        }
185    }
186}
187
188fn extract_text_initials(text: &str) -> String {
189    let mut result = text
190        .split(" ")
191        .flat_map(|word| word.chars().next().map(|c| c.to_string()))
192        .take(2)
193        .collect::<Vec<String>>()
194        .join("");
195
196    if result.len() == 1 {
197        result = text.chars().take(2).collect::<String>();
198    }
199
200    result.to_uppercase()
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use gpui::Rgba;
207
208    /// WCAG 2.1 relative luminance.
209    fn luminance(color: Hsla) -> f32 {
210        let channel = |c: f32| {
211            if c <= 0.03928 {
212                c / 12.92
213            } else {
214                ((c + 0.055) / 1.055).powf(2.4)
215            }
216        };
217
218        let rgb: Rgba = color.into();
219        0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b)
220    }
221
222    fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
223        let (a, b) = (luminance(a), luminance(b));
224        let (lighter, darker) = if a > b { (a, b) } else { (b, a) };
225        (lighter + 0.05) / (darker + 0.05)
226    }
227
228    fn ring(is_dark: bool) -> impl Iterator<Item = (f32, IdentityColor)> {
229        (0..IdentityColor::HUES).map(move |step| {
230            let hue = step as f32 * IdentityColor::HUE_STEP;
231            (hue, IdentityColor::from_hue(hue, is_dark))
232        })
233    }
234
235    #[test]
236    fn identity_colors_stay_legible_on_every_hue() {
237        for is_dark in [false, true] {
238            for (hue, color) in ring(is_dark) {
239                let ratio = contrast_ratio(color.foreground, color.background);
240
241                assert!(
242                    ratio >= 4.5,
243                    "hue {hue} (dark: {is_dark}) has contrast {ratio:.2}, below WCAG AA"
244                );
245            }
246        }
247    }
248
249    /// A color pushed past what sRGB holds at its lightness comes back clamped
250    /// to the gamut edge, which HSL reports as full saturation. The border
251    /// carries the most chroma that clears this on every hue; raising it would
252    /// silently flatten a third of the ring.
253    #[test]
254    fn identity_borders_stay_inside_the_srgb_gamut() {
255        for is_dark in [false, true] {
256            for (hue, color) in ring(is_dark) {
257                assert!(
258                    color.border.s < 1.,
259                    "border at hue {hue} (dark: {is_dark}) is clamped to the sRGB gamut edge"
260                );
261            }
262        }
263    }
264
265    #[test]
266    fn test_avatar_text_initials() {
267        assert_eq!(extract_text_initials(&"Jason Lee"), "JL".to_string());
268        assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string());
269        assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string());
270    }
271
272    #[gpui::test]
273    fn test_avatar_builder(_cx: &mut gpui::TestAppContext) {
274        let avatar = Avatar::new()
275            .name("Jason Lee")
276            .placeholder(Icon::new(IconName::User))
277            .large();
278
279        assert_eq!(avatar.name, Some(SharedString::from("Jason Lee")));
280        assert_eq!(avatar.short_name, SharedString::from("JL"));
281        assert_eq!(avatar.size, Size::Large);
282    }
283}