Skip to main content

egui_components/
avatar.rs

1//! `Avatar` — a circular (or rounded-square) user thumbnail.
2//!
3//! egui has no built-in image loader, so this port renders the *fallback*
4//! representation gpui-component shows while/instead of an image: a colored
5//! disc with the user's initials. Build it from a display name with
6//! [`Avatar::from_name`] (initials + a deterministic color are derived for
7//! you) or set the initials and colors explicitly.
8//!
9//! ```ignore
10//! ui.add(sc::Avatar::from_name("Ada Lovelace").status(sc::AvatarStatus::Online));
11//! ```
12
13use egui::{vec2, Color32, FontId, Response, Sense, Stroke, Ui, Widget};
14use egui_components_theme::{Theme, ThemeColor};
15
16/// Outline shape of the avatar.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub enum AvatarShape {
19    #[default]
20    Circle,
21    /// Rounded square.
22    Square,
23}
24
25/// Optional presence indicator drawn as a dot in the bottom-right corner.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum AvatarStatus {
28    Online,
29    Offline,
30    Busy,
31    Away,
32}
33
34pub struct Avatar {
35    initials: String,
36    size: f32,
37    shape: AvatarShape,
38    bg: Option<Color32>,
39    fg: Option<Color32>,
40    status: Option<AvatarStatus>,
41}
42
43impl Avatar {
44    /// Create an avatar from already-computed initials (rendered verbatim).
45    pub fn new(initials: impl Into<String>) -> Self {
46        Self {
47            initials: initials.into(),
48            size: 40.0,
49            shape: AvatarShape::Circle,
50            bg: None,
51            fg: None,
52            status: None,
53        }
54    }
55
56    /// Derive initials (up to two letters) and a deterministic background
57    /// color from a display name.
58    pub fn from_name(name: impl AsRef<str>) -> Self {
59        let name = name.as_ref();
60        let initials: String = name
61            .split_whitespace()
62            .filter_map(|w| w.chars().next())
63            .take(2)
64            .collect::<String>()
65            .to_uppercase();
66        let mut avatar = Self::new(if initials.is_empty() {
67            "?".to_string()
68        } else {
69            initials
70        });
71        avatar.bg = Some(color_seed(name));
72        avatar
73    }
74
75    pub fn size(mut self, px: f32) -> Self {
76        self.size = px;
77        self
78    }
79    pub fn small(self) -> Self {
80        self.size(28.0)
81    }
82    pub fn large(self) -> Self {
83        self.size(56.0)
84    }
85    pub fn shape(mut self, s: AvatarShape) -> Self {
86        self.shape = s;
87        self
88    }
89    pub fn square(self) -> Self {
90        self.shape(AvatarShape::Square)
91    }
92    /// Override the background fill (otherwise theme/seed derived).
93    pub fn background(mut self, c: Color32) -> Self {
94        self.bg = Some(c);
95        self
96    }
97    /// Override the initials color (otherwise contrast-picked).
98    pub fn foreground(mut self, c: Color32) -> Self {
99        self.fg = Some(c);
100        self
101    }
102    pub fn status(mut self, s: AvatarStatus) -> Self {
103        self.status = Some(s);
104        self
105    }
106}
107
108impl Widget for Avatar {
109    fn ui(self, ui: &mut Ui) -> Response {
110        let theme = Theme::get(ui.ctx());
111        let c = theme.colors;
112
113        let (rect, response) = ui.allocate_exact_size(vec2(self.size, self.size), Sense::hover());
114
115        if ui.is_rect_visible(rect) {
116            let bg = self.bg.unwrap_or(c.secondary_background);
117            let fg = self.fg.unwrap_or_else(|| contrast_on(bg));
118            let painter = ui.painter();
119
120            match self.shape {
121                AvatarShape::Circle => {
122                    painter.circle_filled(rect.center(), self.size * 0.5, bg);
123                }
124                AvatarShape::Square => {
125                    painter.rect_filled(
126                        rect,
127                        egui::CornerRadius::same((self.size * 0.22) as u8),
128                        bg,
129                    );
130                }
131            }
132
133            let font = FontId::proportional(self.size * 0.4);
134            let galley = ui
135                .ctx()
136                .fonts_mut(|f| f.layout_no_wrap(self.initials.clone(), font, fg));
137            painter.galley_with_override_text_color(
138                rect.center() - galley.size() * 0.5,
139                galley,
140                fg,
141            );
142
143            if let Some(status) = self.status {
144                let dot_r = (self.size * 0.16).max(3.5);
145                let offset = self.size * 0.5 - dot_r * 0.7;
146                let center = rect.center() + vec2(offset, offset);
147                // Ring in the surface color so the dot reads against the avatar.
148                painter.circle_filled(center, dot_r + theme.metrics.border_width, c.background);
149                painter.circle(
150                    center,
151                    dot_r,
152                    status_color(&c, status),
153                    Stroke::NONE,
154                );
155            }
156        }
157
158        response
159    }
160}
161
162fn status_color(c: &ThemeColor, status: AvatarStatus) -> Color32 {
163    match status {
164        AvatarStatus::Online => c.success_background,
165        AvatarStatus::Offline => c.muted_foreground,
166        AvatarStatus::Busy => c.danger_background,
167        AvatarStatus::Away => c.warning_background,
168    }
169}
170
171/// Pick black/white initials for legibility against `bg`.
172fn contrast_on(bg: Color32) -> Color32 {
173    let luminance = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
174    if luminance > 140.0 {
175        Color32::from_rgb(0x0a, 0x0a, 0x0a)
176    } else {
177        Color32::from_rgb(0xfa, 0xfa, 0xfa)
178    }
179}
180
181/// Deterministic, pleasant background color derived from a name. Uses a fixed
182/// hue palette so the same name always yields the same color.
183fn color_seed(name: &str) -> Color32 {
184    // Small FNV-1a hash — avoids pulling in `DefaultHasher` randomized state.
185    let mut hash: u32 = 0x811c_9dc5;
186    for b in name.bytes() {
187        hash ^= b as u32;
188        hash = hash.wrapping_mul(0x0100_0193);
189    }
190    const PALETTE: [Color32; 6] = [
191        Color32::from_rgb(0x3b, 0x82, 0xf6), // blue
192        Color32::from_rgb(0x10, 0xb9, 0x81), // green
193        Color32::from_rgb(0xf5, 0x9e, 0x0b), // amber
194        Color32::from_rgb(0xef, 0x44, 0x44), // red
195        Color32::from_rgb(0x8b, 0x5c, 0xf6), // violet
196        Color32::from_rgb(0x06, 0xb6, 0xd4), // cyan
197    ];
198    PALETTE[(hash as usize) % PALETTE.len()]
199}