1use crate::prelude::*;
2
3use documented::Documented;
4use gpui::{AnyElement, Hsla, ImageSource, Img, IntoElement, Styled, img};
5
6#[derive(IntoElement, Documented, RegisterComponent)]
18pub struct Avatar {
19 image: Img,
20 size: Option<AbsoluteLength>,
21 border_color: Option<Hsla>,
22 indicator: Option<AnyElement>,
23}
24
25impl Avatar {
26 pub fn new(src: impl Into<ImageSource>) -> Self {
28 Avatar {
29 image: img(src),
30 size: None,
31 border_color: None,
32 indicator: None,
33 }
34 }
35
36 pub fn grayscale(mut self, grayscale: bool) -> Self {
46 self.image = self.image.grayscale(grayscale);
47 self
48 }
49
50 pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
56 self.border_color = Some(color.into());
57 self
58 }
59
60 pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
62 self.size = size.into().map(Into::into);
63 self
64 }
65
66 pub fn indicator<E: IntoElement>(mut self, indicator: impl Into<Option<E>>) -> Self {
68 self.indicator = indicator.into().map(IntoElement::into_any_element);
69 self
70 }
71}
72
73impl RenderOnce for Avatar {
74 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
75 let border_width = if self.border_color.is_some() {
76 px(1.)
77 } else {
78 px(0.)
79 };
80
81 let image_size = self.size.unwrap_or_else(|| px(32.).into());
83 let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
84 let border_color = self.border_color.unwrap_or_else(|| semantic::border(cx));
85 let fallback_icon_color = semantic::icon_muted(cx);
86 let fallback_bg = semantic::elevated_surface(cx);
88
89 div()
90 .size(container_size)
91 .rounded_full()
92 .when(self.border_color.is_some(), |this| {
93 this.border(border_width).border_color(border_color)
94 })
95 .child(
96 self.image
97 .size(image_size)
98 .rounded_full()
99 .bg(fallback_bg)
100 .with_fallback(move || {
101 h_flex()
102 .size_full()
103 .justify_center()
104 .rounded_full()
105 .bg(fallback_bg)
106 .child(
107 Icon::new(IconName::User)
108 .color(Color::Custom(fallback_icon_color))
109 .size(IconSize::Small),
110 )
111 .into_any_element()
112 }),
113 )
114 .children(self.indicator.map(|indicator| div().child(indicator)))
115 }
116}
117
118use gpui::AnyView;
119
120#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
123pub enum AudioStatus {
124 Muted,
126 Deafened,
128}
129
130#[derive(IntoElement)]
132pub struct AvatarAudioStatusIndicator {
133 audio_status: AudioStatus,
134 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
135}
136
137impl AvatarAudioStatusIndicator {
138 pub fn new(audio_status: AudioStatus) -> Self {
140 Self {
141 audio_status,
142 tooltip: None,
143 }
144 }
145
146 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
148 self.tooltip = Some(Box::new(tooltip));
149 self
150 }
151}
152
153impl RenderOnce for AvatarAudioStatusIndicator {
154 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155 let icon_size = IconSize::Indicator;
156
157 let width_in_px = icon_size.rems() * window.rem_size();
158 let padding_x = px(4.);
159
160 div()
161 .absolute()
162 .bottom(rems_from_px(-3.))
163 .right(rems_from_px(-6.))
164 .w(width_in_px + padding_x)
165 .h(icon_size.rems())
166 .child(
167 h_flex()
168 .id("muted-indicator")
169 .justify_center()
170 .px(padding_x)
171 .py(px(2.))
172 .bg(cx.theme().status().error_background)
173 .rounded_sm()
174 .child(
175 Icon::new(match self.audio_status {
176 AudioStatus::Muted => IconName::MicMute,
177 AudioStatus::Deafened => IconName::AudioOff,
178 })
179 .size(icon_size)
180 .color(Color::Error),
181 )
182 .when_some(self.tooltip, |this, tooltip| {
183 this.tooltip(move |window, cx| tooltip(window, cx))
184 }),
185 )
186 }
187}
188
189#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
191pub enum CollaboratorAvailability {
192 Free,
193 Busy,
194}
195
196#[derive(IntoElement)]
198pub struct AvatarAvailabilityIndicator {
199 availability: CollaboratorAvailability,
200 avatar_size: Option<Pixels>,
201}
202
203impl AvatarAvailabilityIndicator {
204 pub fn new(availability: CollaboratorAvailability) -> Self {
206 Self {
207 availability,
208 avatar_size: None,
209 }
210 }
211
212 pub fn avatar_size(mut self, size: impl Into<Option<Pixels>>) -> Self {
214 self.avatar_size = size.into();
215 self
216 }
217}
218
219impl RenderOnce for AvatarAvailabilityIndicator {
220 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
221 let avatar_size = self.avatar_size.unwrap_or_else(|| window.rem_size());
222
223 let indicator_size = (avatar_size * 0.4).round();
225
226 div()
227 .absolute()
228 .bottom_0()
229 .right_0()
230 .size(indicator_size)
231 .rounded(indicator_size)
232 .bg(match self.availability {
233 CollaboratorAvailability::Free => cx.theme().status().created,
234 CollaboratorAvailability::Busy => cx.theme().status().deleted,
235 })
236 }
237}
238
239impl Component for Avatar {
241 fn scope() -> ComponentScope {
242 ComponentScope::Collaboration
243 }
244
245 fn description() -> Option<&'static str> {
246 Some(Avatar::DOCS)
247 }
248
249 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
250 let example_avatar = "https://avatars.githubusercontent.com/u/1714999?v=4";
251
252 Some(
253 v_flex()
254 .gap_6()
255 .children(vec![
256 example_group(vec![
257 single_example("Default", Avatar::new(example_avatar).into_any_element()),
258 single_example(
259 "Grayscale",
260 Avatar::new(example_avatar)
261 .grayscale(true)
262 .into_any_element(),
263 ),
264 single_example(
265 "Border",
266 Avatar::new(example_avatar)
267 .border_color(cx.theme().colors().border)
268 .into_any_element(),
269 ).description("Can be used to create visual space by setting the border color to match the background, which creates the appearance of a gap around the avatar."),
270 ]),
271 example_group_with_title(
272 "Sizes (24-64px)",
273 vec![
274 single_example(
275 "24px",
276 Avatar::new(example_avatar)
277 .size(px(24.))
278 .into_any_element(),
279 ),
280 single_example(
281 "32px",
282 Avatar::new(example_avatar)
283 .size(px(32.))
284 .into_any_element(),
285 ),
286 single_example(
287 "48px",
288 Avatar::new(example_avatar)
289 .size(px(48.))
290 .into_any_element(),
291 ),
292 single_example(
293 "64px",
294 Avatar::new(example_avatar)
295 .size(px(64.))
296 .into_any_element(),
297 ),
298 ],
299 ),
300 example_group_with_title(
301 "Fallback (broken image → icon)",
302 vec![
303 single_example(
304 "Icon Fallback",
305 Avatar::new("not-a-real-image-source")
306 .size(px(48.))
307 .into_any_element(),
308 ).description("When the image source fails to load, falls back to a `IconName::User` glyph on a neutral background."),
309 ],
310 ),
311 example_group_with_title(
312 "Indicator Styles",
313 vec![
314 single_example(
315 "Muted",
316 Avatar::new(example_avatar)
317 .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted))
318 .into_any_element(),
319 ).description("Indicates the collaborator's mic is muted."),
320 single_example(
321 "Deafened",
322 Avatar::new(example_avatar)
323 .indicator(AvatarAudioStatusIndicator::new(
324 AudioStatus::Deafened,
325 ))
326 .into_any_element(),
327 ).description("Indicates that both the collaborator's mic and audio are muted."),
328 single_example(
329 "Availability: Free",
330 Avatar::new(example_avatar)
331 .indicator(AvatarAvailabilityIndicator::new(
332 CollaboratorAvailability::Free,
333 ))
334 .into_any_element(),
335 ).description("Indicates that the person is free, usually meaning they are not in a call."),
336 single_example(
337 "Availability: Busy",
338 Avatar::new(example_avatar)
339 .indicator(AvatarAvailabilityIndicator::new(
340 CollaboratorAvailability::Busy,
341 ))
342 .into_any_element(),
343 ).description("Indicates that the person is busy, usually meaning they are in a channel or direct call."),
344 ],
345 ),
346 ])
347 .into_any_element(),
348 )
349 }
350}