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