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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum AvatarSize {
13 Sm,
15 #[default]
17 Default,
18 Lg,
20}
21
22#[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 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 pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
64 self.src = Some(source.into());
65 self
66 }
67
68 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
70 self.text = Some(initials(&name.into()));
71 self
72 }
73
74 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
76 self.icon = Some(icon.into());
77 self
78 }
79
80 pub fn size(mut self, size: AvatarSize) -> Self {
82 self.size = size;
83 self
84 }
85
86 fn text(mut self, text: SharedString) -> Self {
88 self.text = Some(text);
89 self
90 }
91
92 fn ring(mut self) -> Self {
95 self.ring = true;
96 self
97 }
98}
99
100fn 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#[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 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 pub fn child(mut self, avatar: Avatar) -> Self {
211 self.avatars.push(avatar);
212 self
213 }
214
215 pub fn children(mut self, avatars: impl IntoIterator<Item = Avatar>) -> Self {
217 self.avatars.extend(avatars);
218 self
219 }
220
221 pub fn limit(mut self, limit: usize) -> Self {
223 self.limit = Some(limit);
224 self
225 }
226
227 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}