Skip to main content

elegance/
avatar.rs

1//! Profile avatars: circular tiles with initials, an optional presence dot,
2//! and an [`AvatarGroup`] for stacked overlap with a `+N` overflow indicator.
3
4use egui::{
5    Color32, FontSelection, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetInfo, WidgetText,
6    WidgetType,
7};
8
9use crate::theme::{with_alpha, Theme};
10
11/// Diameter preset for an [`Avatar`].
12///
13/// Maps to fixed point sizes so groups of avatars line up cleanly across
14/// surfaces. Scale the font and presence-dot proportionally.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum AvatarSize {
17    /// 20 pt. For dense lists or inline chips.
18    XSmall,
19    /// 28 pt. Inline with body text.
20    Small,
21    /// 36 pt. The default.
22    Medium,
23    /// 48 pt. Card headers and identity rows.
24    Large,
25    /// 64 pt. Profile headers and feature screens.
26    XLarge,
27}
28
29impl AvatarSize {
30    fn diameter(self) -> f32 {
31        match self {
32            AvatarSize::XSmall => 20.0,
33            AvatarSize::Small => 28.0,
34            AvatarSize::Medium => 36.0,
35            AvatarSize::Large => 48.0,
36            AvatarSize::XLarge => 64.0,
37        }
38    }
39
40    fn font_size(self) -> f32 {
41        match self {
42            AvatarSize::XSmall => 10.0,
43            AvatarSize::Small => 11.5,
44            AvatarSize::Medium => 13.0,
45            AvatarSize::Large => 16.0,
46            AvatarSize::XLarge => 22.0,
47        }
48    }
49
50    fn dot_diameter(self) -> f32 {
51        match self {
52            AvatarSize::XSmall | AvatarSize::Small => 7.0,
53            AvatarSize::Medium => 10.0,
54            AvatarSize::Large => 12.0,
55            AvatarSize::XLarge => 14.0,
56        }
57    }
58
59    fn dot_border(self) -> f32 {
60        match self {
61            AvatarSize::XSmall | AvatarSize::Small => 1.5,
62            _ => 2.0,
63        }
64    }
65}
66
67/// Background tone for an [`Avatar`]'s initials variant.
68///
69/// When the user passes `None` (the default), the tone is derived
70/// deterministically from the initials text so the same person gets the
71/// same colour everywhere.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
73pub enum AvatarTone {
74    /// Sky blue.
75    Sky,
76    /// Green.
77    Green,
78    /// Amber.
79    Amber,
80    /// Red.
81    Red,
82    /// Purple.
83    Purple,
84    /// Theme-neutral grey. Also used for the `+N` overflow tile.
85    Neutral,
86}
87
88const AUTO_TONES: [AvatarTone; 5] = [
89    AvatarTone::Sky,
90    AvatarTone::Green,
91    AvatarTone::Amber,
92    AvatarTone::Red,
93    AvatarTone::Purple,
94];
95
96impl AvatarTone {
97    /// Pick a tone deterministically from a name or initials.
98    pub fn from_text(s: &str) -> Self {
99        // FNV-1a — stable across runs, unlike the hashing in `std::collections`.
100        let mut h: u32 = 0x811c_9dc5;
101        for b in s.bytes() {
102            h ^= b as u32;
103            h = h.wrapping_mul(0x0100_0193);
104        }
105        AUTO_TONES[(h as usize) % AUTO_TONES.len()]
106    }
107
108    fn colours(self, theme: &Theme) -> (Color32, Color32) {
109        let p = &theme.palette;
110        match self {
111            AvatarTone::Sky => (with_alpha(p.sky, 51), p.sky),
112            AvatarTone::Green => (with_alpha(p.green, 46), p.success),
113            AvatarTone::Amber => (with_alpha(p.warning, 51), p.warning),
114            AvatarTone::Red => (with_alpha(p.danger, 51), p.danger),
115            AvatarTone::Purple => (with_alpha(p.purple, 51), p.purple),
116            AvatarTone::Neutral => (with_alpha(p.text_muted, 40), p.text_muted),
117        }
118    }
119}
120
121/// Presence-dot state painted at the avatar's bottom-right corner.
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
123pub enum AvatarPresence {
124    /// Connected and active — green.
125    Online,
126    /// Do-not-disturb — red.
127    Busy,
128    /// Idle / away — amber.
129    Away,
130    /// Disconnected — neutral grey.
131    Offline,
132}
133
134impl AvatarPresence {
135    fn colour(self, theme: &Theme) -> Color32 {
136        let p = &theme.palette;
137        match self {
138            AvatarPresence::Online => p.success,
139            AvatarPresence::Busy => p.danger,
140            AvatarPresence::Away => p.warning,
141            AvatarPresence::Offline => p.text_faint,
142        }
143    }
144}
145
146/// A circular profile avatar with initials and an optional presence dot.
147///
148/// ```no_run
149/// # use elegance::{Avatar, AvatarPresence, AvatarSize, AvatarTone};
150/// # egui::__run_test_ui(|ui| {
151/// ui.add(
152///     Avatar::new("AL")
153///         .size(AvatarSize::Large)
154///         .tone(AvatarTone::Sky)
155///         .presence(AvatarPresence::Online),
156/// );
157/// # });
158/// ```
159#[must_use = "Add the avatar with `ui.add(...)`."]
160pub struct Avatar {
161    initials: WidgetText,
162    size: AvatarSize,
163    tone: Option<AvatarTone>,
164    presence: Option<AvatarPresence>,
165    surface: Option<Color32>,
166    ring: bool,
167}
168
169impl std::fmt::Debug for Avatar {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("Avatar")
172            .field("initials", &self.initials.text())
173            .field("size", &self.size)
174            .field("tone", &self.tone)
175            .field("presence", &self.presence)
176            .field("ring", &self.ring)
177            .finish()
178    }
179}
180
181impl Avatar {
182    /// Create an avatar that displays the given initials. One or two
183    /// uppercase glyphs read best (`"AL"`, `"MR"`, `"??"`).
184    pub fn new(initials: impl Into<WidgetText>) -> Self {
185        Self {
186            initials: initials.into(),
187            size: AvatarSize::Medium,
188            tone: None,
189            presence: None,
190            surface: None,
191            ring: false,
192        }
193    }
194
195    /// Pick a size preset. Default: [`AvatarSize::Medium`].
196    #[inline]
197    pub fn size(mut self, size: AvatarSize) -> Self {
198        self.size = size;
199        self
200    }
201
202    /// Pin the tone explicitly. When unset, the tone is derived from the
203    /// initials so the same name always gets the same colour.
204    #[inline]
205    pub fn tone(mut self, tone: AvatarTone) -> Self {
206        self.tone = Some(tone);
207        self
208    }
209
210    /// Render a presence dot in the bottom-right corner. Default: none.
211    #[inline]
212    pub fn presence(mut self, presence: AvatarPresence) -> Self {
213        self.presence = Some(presence);
214        self
215    }
216
217    /// Override the surface colour the avatar is painted against. Drives
218    /// the inner border on the presence dot and the colour of the outer
219    /// ring (when [`Avatar::ring`] is set). Defaults to the page
220    /// background; pass `theme.palette.card` when placing an avatar inside
221    /// a [`Card`](crate::Card) so the presence dot punches cleanly out of
222    /// the card surface.
223    #[inline]
224    pub fn surface(mut self, color: Color32) -> Self {
225        self.surface = Some(color);
226        self
227    }
228
229    /// Paint a 2 pt outer ring in the surface colour around the disc. Used
230    /// by [`AvatarGroup`] to separate overlapping members; rarely needed
231    /// for solo avatars. Default: off.
232    #[inline]
233    pub fn ring(mut self, ring: bool) -> Self {
234        self.ring = ring;
235        self
236    }
237
238    fn paint(&self, ui: &mut Ui, rect: egui::Rect) {
239        if !ui.is_rect_visible(rect) {
240            return;
241        }
242        let theme = Theme::current(ui.ctx());
243        let p = &theme.palette;
244        let surface = self.surface.unwrap_or(p.bg);
245        let initials_text = self.initials.text();
246        let tone = self
247            .tone
248            .unwrap_or_else(|| AvatarTone::from_text(initials_text));
249        let (bg, fg) = tone.colours(&theme);
250
251        let painter = ui.painter();
252        let center = rect.center();
253        let r = self.size.diameter() * 0.5;
254
255        painter.circle_filled(center, r, bg);
256
257        if !initials_text.is_empty() {
258            let font_size = self.size.font_size();
259            let galley = WidgetText::from(
260                egui::RichText::new(initials_text)
261                    .color(fg)
262                    .size(font_size)
263                    .strong(),
264            )
265            .into_galley(
266                ui,
267                Some(egui::TextWrapMode::Extend),
268                f32::INFINITY,
269                FontSelection::FontId(egui::FontId::proportional(font_size)),
270            );
271            let pos = center - galley.size() * 0.5;
272            ui.painter().galley(pos, galley, fg);
273        }
274
275        if self.ring {
276            ui.painter()
277                .circle_stroke(center, r + 1.0, Stroke::new(2.0, surface));
278        }
279
280        if let Some(presence) = self.presence {
281            let dot_d = self.size.dot_diameter();
282            let border_w = self.size.dot_border();
283            let off = r + 1.0 - dot_d * 0.5;
284            let dot_center = center + Vec2::splat(off);
285            let outer_r = dot_d * 0.5 + border_w;
286            ui.painter().circle_filled(dot_center, outer_r, surface);
287            ui.painter()
288                .circle_filled(dot_center, dot_d * 0.5, presence.colour(&theme));
289        }
290    }
291}
292
293impl Widget for Avatar {
294    fn ui(self, ui: &mut Ui) -> Response {
295        let diameter = self.size.diameter();
296        let (rect, response) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover());
297        self.paint(ui, rect);
298
299        let label = self.initials.text();
300        let owned = if label.is_empty() {
301            "avatar".to_string()
302        } else {
303            label.to_string()
304        };
305        response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &owned));
306        response
307    }
308}
309
310/// A row of overlapping avatars with an optional `+N` overflow tile.
311///
312/// All members share the group's size and surface so the punch-out ring
313/// reads cleanly. Pass an explicit surface when placing the group on a
314/// card or non-default background.
315///
316/// ```no_run
317/// # use elegance::{Avatar, AvatarGroup, AvatarSize, AvatarTone};
318/// # egui::__run_test_ui(|ui| {
319/// ui.add(
320///     AvatarGroup::new()
321///         .size(AvatarSize::Medium)
322///         .item(Avatar::new("AL").tone(AvatarTone::Sky))
323///         .item(Avatar::new("MR").tone(AvatarTone::Green))
324///         .item(Avatar::new("JK").tone(AvatarTone::Amber))
325///         .overflow(7),
326/// );
327/// # });
328/// ```
329#[must_use = "Add the group with `ui.add(...)`."]
330pub struct AvatarGroup {
331    items: Vec<Avatar>,
332    overflow: Option<usize>,
333    overlap: f32,
334    surface: Option<Color32>,
335    size: AvatarSize,
336}
337
338impl std::fmt::Debug for AvatarGroup {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        f.debug_struct("AvatarGroup")
341            .field("items", &self.items.len())
342            .field("overflow", &self.overflow)
343            .field("overlap", &self.overlap)
344            .field("size", &self.size)
345            .finish()
346    }
347}
348
349impl Default for AvatarGroup {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355impl AvatarGroup {
356    /// Create an empty group at the default medium size.
357    pub fn new() -> Self {
358        Self {
359            items: Vec::new(),
360            overflow: None,
361            overlap: 10.0,
362            surface: None,
363            size: AvatarSize::Medium,
364        }
365    }
366
367    /// Append an avatar. The group's size and surface override anything
368    /// set on the passed avatar so members share a uniform diameter.
369    #[inline]
370    pub fn item(mut self, avatar: Avatar) -> Self {
371        self.items.push(avatar);
372        self
373    }
374
375    /// Show a trailing `+N` neutral tile. Counts beyond the on-screen
376    /// items.
377    #[inline]
378    pub fn overflow(mut self, n: usize) -> Self {
379        self.overflow = Some(n);
380        self
381    }
382
383    /// Pixels of overlap between adjacent avatars. Default: 10 pt.
384    #[inline]
385    pub fn overlap(mut self, overlap: f32) -> Self {
386        self.overlap = overlap;
387        self
388    }
389
390    /// Pin the size for every member of the group. Default:
391    /// [`AvatarSize::Medium`].
392    #[inline]
393    pub fn size(mut self, size: AvatarSize) -> Self {
394        self.size = size;
395        self
396    }
397
398    /// Override the surface colour. Drives the punch-out ring around each
399    /// member and the inner border on any presence dots. Defaults to the
400    /// page background.
401    #[inline]
402    pub fn surface(mut self, color: Color32) -> Self {
403        self.surface = Some(color);
404        self
405    }
406}
407
408impl Widget for AvatarGroup {
409    fn ui(self, ui: &mut Ui) -> Response {
410        let theme = Theme::current(ui.ctx());
411        let surface = self.surface.unwrap_or(theme.palette.bg);
412        let diameter = self.size.diameter();
413        let count = self.items.len() + usize::from(self.overflow.is_some());
414        if count == 0 {
415            let (_, response) = ui.allocate_exact_size(Vec2::ZERO, Sense::hover());
416            return response;
417        }
418        let total_w = diameter * count as f32 - self.overlap * (count.saturating_sub(1)) as f32;
419        let (rect, response) = ui.allocate_exact_size(Vec2::new(total_w, diameter), Sense::hover());
420
421        let mut x = rect.left();
422        for avatar in self.items {
423            let avatar = avatar.size(self.size).surface(surface).ring(true);
424            let cell = egui::Rect::from_min_size(egui::pos2(x, rect.top()), Vec2::splat(diameter));
425            avatar.paint(ui, cell);
426            x += diameter - self.overlap;
427        }
428        if let Some(n) = self.overflow {
429            let label = format!("+{}", n);
430            let avatar = Avatar::new(label)
431                .size(self.size)
432                .tone(AvatarTone::Neutral)
433                .surface(surface)
434                .ring(true);
435            let cell = egui::Rect::from_min_size(egui::pos2(x, rect.top()), Vec2::splat(diameter));
436            avatar.paint(ui, cell);
437        }
438
439        response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "avatar group"));
440        response
441    }
442}