Skip to main content

guise/data/
avatargroup.rs

1//! `AvatarGroup` — a row of overlapping avatars with an optional overflow chip.
2
3use gpui::prelude::*;
4use gpui::{div, px, App, FontWeight, IntoElement, SharedString, Window};
5
6use crate::theme::{theme, ColorName, Size};
7
8const PALETTE: [ColorName; 6] = [
9    ColorName::Blue,
10    ColorName::Teal,
11    ColorName::Grape,
12    ColorName::Orange,
13    ColorName::Pink,
14    ColorName::Lime,
15];
16
17/// A stack of overlapping avatars. The Mantine `Avatar.Group`.
18#[derive(IntoElement)]
19pub struct AvatarGroup {
20    items: Vec<SharedString>,
21    size: Size,
22    limit: Option<usize>,
23}
24
25impl AvatarGroup {
26    pub fn new() -> Self {
27        AvatarGroup {
28            items: Vec::new(),
29            size: Size::Md,
30            limit: None,
31        }
32    }
33
34    pub fn avatar(mut self, initials: impl Into<SharedString>) -> Self {
35        self.items.push(initials.into());
36        self
37    }
38
39    pub fn avatars<I, S>(mut self, items: I) -> Self
40    where
41        I: IntoIterator<Item = S>,
42        S: Into<SharedString>,
43    {
44        self.items.extend(items.into_iter().map(Into::into));
45        self
46    }
47
48    pub fn size(mut self, size: Size) -> Self {
49        self.size = size;
50        self
51    }
52
53    /// Show at most `limit` avatars; the rest collapse into a `+N` chip.
54    pub fn limit(mut self, limit: usize) -> Self {
55        self.limit = Some(limit);
56        self
57    }
58}
59
60impl Default for AvatarGroup {
61    fn default() -> Self {
62        AvatarGroup::new()
63    }
64}
65
66impl RenderOnce for AvatarGroup {
67    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
68        let t = theme(cx);
69        let dim = super::avatar::avatar_size(self.size);
70        let ring = t.body().hsla();
71        let overflow_bg = t
72            .color(ColorName::Gray, if t.scheme.is_dark() { 6 } else { 3 })
73            .hsla();
74        let overflow_fg = t.text().hsla();
75        let dark = t.scheme.is_dark();
76
77        let total = self.items.len();
78        let shown = self.limit.unwrap_or(total).min(total);
79        let overflow = total - shown;
80
81        // A circle with a ring border so overlaps read cleanly.
82        let bubble = |bg, fg, content: SharedString, first: bool| {
83            let mut b = div()
84                .w(px(dim))
85                .h(px(dim))
86                .flex()
87                .items_center()
88                .justify_center()
89                .rounded(px(dim))
90                .border_2()
91                .border_color(ring)
92                .bg(bg)
93                .text_color(fg)
94                .text_size(px(dim * 0.38))
95                .font_weight(FontWeight::SEMIBOLD)
96                .child(content);
97            if !first {
98                b = b.ml(px(-(dim * 0.3)));
99            }
100            b
101        };
102
103        let mut row = div().flex().items_center();
104        for (i, initials) in self.items.into_iter().take(shown).enumerate() {
105            let name = PALETTE[i % PALETTE.len()];
106            let bg = t.color(name, if dark { 8 } else { 1 }).hsla();
107            let fg = t.color(name, if dark { 2 } else { 8 }).hsla();
108            row = row.child(bubble(bg, fg, initials, i == 0));
109        }
110        if overflow > 0 {
111            row = row.child(bubble(
112                overflow_bg,
113                overflow_fg,
114                SharedString::from(format!("+{overflow}")),
115                shown == 0,
116            ));
117        }
118        row
119    }
120}