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#[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 pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
42 self.src = Some(source.into());
43 self
44 }
45
46 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 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 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#[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 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 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 #[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}