Skip to main content

sequoia_elements/
avatar.rs

1use sequoia_core::{Color, Edges, Length, Size};
2use sequoia_layout::{LayoutCtx, LayoutStyle};
3use sequoia_style::AvatarStyle;
4use sequoia_view::{AssetRegistry, FontWeight, PaintCtx, Unit, View, icon_key};
5
6const SIZE: f32 = 32.0;
7const FONT_RATIO: f32 = 0.38;
8const ICON_RATIO: f32 = 0.55;
9
10pub fn initials_for(name: &str) -> String {
11    let mut words = name.split_whitespace();
12    let first = words.next().unwrap_or("");
13    let last = words.last();
14    let mut out = String::new();
15    if let Some(c) = first.chars().next() {
16        out.extend(c.to_uppercase());
17    }
18    if let Some(last) = last
19        && let Some(c) = last.chars().next()
20    {
21        out.extend(c.to_uppercase());
22    }
23    if out.is_empty() { "?".to_string() } else { out }
24}
25
26pub fn avatar_color_for(name: &str) -> Color {
27    const PALETTE: [Color; 8] = [
28        Color::hex(0x356FD1),
29        Color::hex(0x7A5ACD),
30        Color::hex(0x2E8B57),
31        Color::hex(0xB3541E),
32        Color::hex(0xA03A5A),
33        Color::hex(0x2A9D8F),
34        Color::hex(0x8A6D1B),
35        Color::hex(0x5A6E7F),
36    ];
37    let mut hash: u32 = 5381;
38    for b in name.bytes() {
39        hash = hash.wrapping_mul(33).wrapping_add(b as u32);
40    }
41
42    PALETTE[(hash as usize) % PALETTE.len()]
43}
44
45pub struct Avatar {
46    name: String,
47    initials: Option<String>,
48    icon: Option<String>,
49    size: f32,
50    auto_color: bool,
51    style: Option<AvatarStyle>,
52    background: Option<Color>,
53    text_color: Option<Color>,
54    font_size: Option<f32>,
55    radius: Option<f32>,
56    margin: Edges,
57}
58
59impl Avatar {
60    pub fn named(name: impl Into<String>) -> Self {
61        Self {
62            name: name.into(),
63            initials: None,
64            icon: None,
65            size: SIZE,
66            auto_color: true,
67            style: None,
68            background: None,
69            text_color: None,
70            font_size: None,
71            radius: None,
72            margin: Edges::ZERO,
73        }
74    }
75
76    pub fn icon(name: impl Into<String>) -> Self {
77        Self {
78            name: String::new(),
79            initials: None,
80            icon: Some(name.into()),
81            size: SIZE,
82            auto_color: true,
83            style: None,
84            background: None,
85            text_color: None,
86            font_size: None,
87            radius: None,
88            margin: Edges::ZERO,
89        }
90    }
91
92    pub fn initials(mut self, initials: impl Into<String>) -> Self {
93        self.initials = Some(initials.into());
94        self
95    }
96
97    pub fn size(mut self, px: f32) -> Self {
98        self.size = px.max(8.0);
99        self
100    }
101
102    pub fn auto_color(mut self, on: bool) -> Self {
103        self.auto_color = on;
104        self
105    }
106
107    pub fn style(mut self, style: AvatarStyle) -> Self {
108        self.style = Some(style);
109        self
110    }
111
112    pub fn background(mut self, color: Color) -> Self {
113        self.background = Some(color);
114        self
115    }
116
117    pub fn text_color(mut self, color: Color) -> Self {
118        self.text_color = Some(color);
119        self
120    }
121
122    pub fn font_size(mut self, size: f32) -> Self {
123        self.font_size = Some(size);
124        self
125    }
126
127    pub fn radius(mut self, radius: f32) -> Self {
128        self.radius = Some(radius);
129        self
130    }
131
132    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
133        let m = margin.into();
134        self.margin = Edges::only(
135            sanitize_margin(m.left),
136            sanitize_margin(m.top),
137            sanitize_margin(m.right),
138            sanitize_margin(m.bottom),
139        );
140        self
141    }
142
143    pub fn margin_xy(self, x: f32, y: f32) -> Self {
144        self.margin(Edges::xy(x, y))
145    }
146
147    pub fn margin_x(mut self, x: f32) -> Self {
148        let x = sanitize_margin(x);
149        self.margin.left = x;
150        self.margin.right = x;
151        self
152    }
153
154    pub fn margin_y(mut self, y: f32) -> Self {
155        let y = sanitize_margin(y);
156        self.margin.top = y;
157        self.margin.bottom = y;
158        self
159    }
160
161    pub fn margin_left(mut self, v: f32) -> Self {
162        self.margin.left = sanitize_margin(v);
163        self
164    }
165
166    pub fn margin_right(mut self, v: f32) -> Self {
167        self.margin.right = sanitize_margin(v);
168        self
169    }
170
171    pub fn margin_top(mut self, v: f32) -> Self {
172        self.margin.top = sanitize_margin(v);
173        self
174    }
175
176    pub fn margin_bottom(mut self, v: f32) -> Self {
177        self.margin.bottom = sanitize_margin(v);
178        self
179    }
180
181    fn label(&self) -> String {
182        self.initials
183            .clone()
184            .unwrap_or_else(|| initials_for(&self.name))
185    }
186}
187
188pub(crate) fn sanitize_margin(v: f32) -> f32 {
189    if v.is_finite() { v.max(0.0) } else { 0.0 }
190}
191
192impl View for Avatar {
193    fn body(&self) -> impl View + 'static {
194        Unit
195    }
196    fn is_leaf(&self) -> bool {
197        true
198    }
199    fn hit_testable(&self) -> bool {
200        false
201    }
202    fn layout_style(&self) -> LayoutStyle {
203        LayoutStyle {
204            width: Length::Px(self.size),
205            height: Length::Px(self.size),
206            margin: self.margin,
207            ..LayoutStyle::default().hit_testable(false)
208        }
209    }
210    fn intrinsic_size(&self, _ctx: &mut LayoutCtx) -> Option<Size> {
211        Some(Size::new(self.size, self.size))
212    }
213    fn paint_self(&self, ctx: &mut PaintCtx) {
214        let rect = ctx.rect;
215        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
216            return;
217        }
218        let theme = ctx.theme();
219        let style = self.style.unwrap_or_default();
220        let side = rect.size.width.min(rect.size.height);
221        let radius = self.radius.or(style.radius).unwrap_or(side * 0.5).max(0.0);
222        let background = self.background.or(style.background).unwrap_or_else(|| {
223            if self.auto_color && self.icon.is_none() {
224                avatar_color_for(&self.name)
225            } else {
226                theme.surface
227            }
228        });
229        ctx.quad(rect, background, radius);
230
231        let text = self.text_color.or(style.text).unwrap_or(theme.text);
232        if let Some(name) = &self.icon {
233            let px = side * ICON_RATIO;
234            if let Some(assets) = ctx.environment.get::<AssetRegistry>() {
235                let raster_px = ((px * ctx.scale_factor()).round() as u32).max(1);
236                if let Ok(sprite) = assets.rasterize_icon(name, raster_px) {
237                    let x = rect.left() + (rect.size.width - px) * 0.5;
238                    let y = rect.top() + (rect.size.height - px) * 0.5;
239                    ctx.draw_sprite(
240                        icon_key(name, raster_px),
241                        sprite.width,
242                        sprite.height,
243                        &sprite.alpha,
244                        sequoia_core::Rect::from_xywh(x, y, px, px),
245                        text,
246                    );
247                    return;
248                }
249            }
250        }
251        let label = self.label();
252        let size = self
253            .font_size
254            .or(style.font_size)
255            .unwrap_or(side * FONT_RATIO);
256        let w = ctx.text_width_weighted(&label, size, FontWeight::SEMIBOLD);
257        let x = rect.left() + (rect.size.width - w) * 0.5;
258        let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
259        ctx.draw_text_weighted(&label, (x, y), size, FontWeight::SEMIBOLD, text);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn initials_take_first_and_last() {
269        assert_eq!(initials_for("Ada Lovelace"), "AL");
270        assert_eq!(initials_for("ada lovelace byron"), "AB");
271        assert_eq!(initials_for("root"), "R");
272        assert_eq!(initials_for("  "), "?");
273        assert_eq!(initials_for(""), "?");
274    }
275
276    #[test]
277    fn color_is_stable_and_spread() {
278        assert_eq!(avatar_color_for("ada"), avatar_color_for("ada"));
279        let mut seen = std::collections::HashSet::new();
280        for name in [
281            "ada", "bob", "ced", "dan", "eli", "fay", "gus", "hal", "ida", "jay",
282        ] {
283            seen.insert(avatar_color_for(name).to_rgb8());
284        }
285        assert!(seen.len() > 1, "palette differs: {seen:?}");
286    }
287}