gpui_ui_kit/
avatar.rs

1//! Avatar component
2//!
3//! User avatars and profile images.
4
5use gpui::prelude::*;
6use gpui::*;
7
8/// Avatar size
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum AvatarSize {
11    /// Extra small (20px)
12    Xs,
13    /// Small (24px)
14    Sm,
15    /// Medium (32px, default)
16    #[default]
17    Md,
18    /// Large (40px)
19    Lg,
20    /// Extra large (48px)
21    Xl,
22    /// 2X large (64px)
23    Xxl,
24}
25
26impl AvatarSize {
27    fn size(&self) -> Pixels {
28        match self {
29            AvatarSize::Xs => px(20.0),
30            AvatarSize::Sm => px(24.0),
31            AvatarSize::Md => px(32.0),
32            AvatarSize::Lg => px(40.0),
33            AvatarSize::Xl => px(48.0),
34            AvatarSize::Xxl => px(64.0),
35        }
36    }
37}
38
39/// Avatar shape
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum AvatarShape {
42    /// Circular (default)
43    #[default]
44    Circle,
45    /// Rounded square
46    Square,
47}
48
49/// An avatar component
50pub struct Avatar {
51    name: Option<SharedString>,
52    src: Option<SharedString>,
53    size: AvatarSize,
54    shape: AvatarShape,
55    status: Option<AvatarStatus>,
56}
57
58/// Avatar online status
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum AvatarStatus {
61    Online,
62    Offline,
63    Away,
64    Busy,
65}
66
67impl AvatarStatus {
68    fn color(&self) -> Rgba {
69        match self {
70            AvatarStatus::Online => rgb(0x2da44e),
71            AvatarStatus::Offline => rgb(0x666666),
72            AvatarStatus::Away => rgb(0xd29922),
73            AvatarStatus::Busy => rgb(0xcc3333),
74        }
75    }
76}
77
78impl Avatar {
79    /// Create a new avatar
80    pub fn new() -> Self {
81        Self {
82            name: None,
83            src: None,
84            size: AvatarSize::default(),
85            shape: AvatarShape::default(),
86            status: None,
87        }
88    }
89
90    /// Set name (used for initials fallback)
91    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
92        self.name = Some(name.into());
93        self
94    }
95
96    /// Set image source
97    pub fn src(mut self, src: impl Into<SharedString>) -> Self {
98        self.src = Some(src.into());
99        self
100    }
101
102    /// Set size
103    pub fn size(mut self, size: AvatarSize) -> Self {
104        self.size = size;
105        self
106    }
107
108    /// Set shape
109    pub fn shape(mut self, shape: AvatarShape) -> Self {
110        self.shape = shape;
111        self
112    }
113
114    /// Set status indicator
115    pub fn status(mut self, status: AvatarStatus) -> Self {
116        self.status = Some(status);
117        self
118    }
119
120    /// Get initials from name
121    fn get_initials(&self) -> String {
122        if let Some(name) = &self.name {
123            name.split_whitespace()
124                .filter_map(|word| word.chars().next())
125                .take(2)
126                .collect::<String>()
127                .to_uppercase()
128        } else {
129            "?".to_string()
130        }
131    }
132
133    /// Get background color based on name hash
134    fn get_bg_color(&self) -> Rgba {
135        if let Some(name) = &self.name {
136            let hash: u32 = name.chars().fold(0u32, |acc, c| acc.wrapping_add(c as u32));
137            let colors = [
138                rgb(0x007acc), // Blue
139                rgb(0x2da44e), // Green
140                rgb(0xd29922), // Yellow
141                rgb(0xcc3333), // Red
142                rgb(0x8b5cf6), // Purple
143                rgb(0x06b6d4), // Cyan
144                rgb(0xf97316), // Orange
145                rgb(0xec4899), // Pink
146            ];
147            colors[(hash as usize) % colors.len()]
148        } else {
149            rgb(0x3a3a3a)
150        }
151    }
152
153    /// Build into element
154    pub fn build(self) -> Div {
155        let size = self.size.size();
156        let initials = self.get_initials();
157        let bg_color = self.get_bg_color();
158
159        let mut avatar = div()
160            .relative()
161            .flex()
162            .items_center()
163            .justify_center()
164            .w(size)
165            .h(size)
166            .bg(bg_color)
167            .text_color(rgb(0xffffff))
168            .overflow_hidden();
169
170        // Apply shape
171        match self.shape {
172            AvatarShape::Circle => {
173                avatar = avatar.rounded_full();
174            }
175            AvatarShape::Square => {
176                avatar = avatar.rounded_md();
177            }
178        }
179
180        // Apply text size based on avatar size
181        avatar = match self.size {
182            AvatarSize::Xs => avatar.text_xs(),
183            AvatarSize::Sm => avatar.text_xs(),
184            AvatarSize::Md => avatar.text_sm(),
185            AvatarSize::Lg => avatar.text_sm(),
186            AvatarSize::Xl => avatar,
187            AvatarSize::Xxl => avatar.text_lg(),
188        };
189
190        // Content: image or initials
191        if let Some(_src) = self.src {
192            // Note: Image loading requires gpui::img()
193            // For now, show initials as fallback
194            avatar = avatar.child(initials);
195        } else {
196            avatar = avatar.font_weight(FontWeight::SEMIBOLD).child(initials);
197        }
198
199        // Status indicator
200        if let Some(status) = self.status {
201            let status_size = match self.size {
202                AvatarSize::Xs | AvatarSize::Sm => px(6.0),
203                AvatarSize::Md | AvatarSize::Lg => px(8.0),
204                AvatarSize::Xl | AvatarSize::Xxl => px(10.0),
205            };
206
207            let status_indicator = div()
208                .absolute()
209                .bottom_0()
210                .right_0()
211                .w(status_size)
212                .h(status_size)
213                .rounded_full()
214                .bg(status.color())
215                .border_2()
216                .border_color(rgb(0x1e1e1e));
217
218            avatar = avatar.child(status_indicator);
219        }
220
221        avatar
222    }
223}
224
225impl Default for Avatar {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231impl IntoElement for Avatar {
232    type Element = Div;
233
234    fn into_element(self) -> Self::Element {
235        self.build()
236    }
237}
238
239/// A group of avatars displayed overlapping
240pub struct AvatarGroup {
241    avatars: Vec<Avatar>,
242    max_display: usize,
243    size: AvatarSize,
244}
245
246impl AvatarGroup {
247    /// Create a new avatar group
248    pub fn new() -> Self {
249        Self {
250            avatars: Vec::new(),
251            max_display: 4,
252            size: AvatarSize::default(),
253        }
254    }
255
256    /// Add avatars
257    pub fn avatars(mut self, avatars: Vec<Avatar>) -> Self {
258        self.avatars = avatars;
259        self
260    }
261
262    /// Set maximum number to display
263    pub fn max_display(mut self, max: usize) -> Self {
264        self.max_display = max;
265        self
266    }
267
268    /// Set size for all avatars
269    pub fn size(mut self, size: AvatarSize) -> Self {
270        self.size = size;
271        self
272    }
273
274    /// Build into element
275    pub fn build(self) -> Div {
276        let size = self.size.size();
277        let overlap = size * 0.3;
278
279        let mut container = div().flex().items_center();
280
281        let display_count = self.avatars.len().min(self.max_display);
282        let remaining = self.avatars.len().saturating_sub(self.max_display);
283
284        for (i, avatar) in self.avatars.into_iter().take(display_count).enumerate() {
285            let avatar_el = avatar.size(self.size).build();
286            let mut wrapper = div().relative();
287
288            if i > 0 {
289                wrapper = wrapper.ml(-overlap);
290            }
291
292            wrapper = wrapper.child(avatar_el);
293            container = container.child(wrapper);
294        }
295
296        // Show remaining count
297        if remaining > 0 {
298            container = container.child(
299                div()
300                    .ml(-overlap)
301                    .flex()
302                    .items_center()
303                    .justify_center()
304                    .w(size)
305                    .h(size)
306                    .rounded_full()
307                    .bg(rgb(0x3a3a3a))
308                    .text_color(rgb(0xcccccc))
309                    .text_xs()
310                    .font_weight(FontWeight::MEDIUM)
311                    .child(format!("+{}", remaining)),
312            );
313        }
314
315        container
316    }
317}
318
319impl Default for AvatarGroup {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325impl IntoElement for AvatarGroup {
326    type Element = Div;
327
328    fn into_element(self) -> Self::Element {
329        self.build()
330    }
331}