Skip to main content

iced_cube/feedback/
avatar.rs

1//! A small picture of a person or team, with initials when there is no
2//! image.
3//!
4//! [`avatar`] takes a name and shows its [`initials`] on a muted
5//! background. Add an [`image`](Avatar::image) to show a picture instead,
6//! and a [`presence`](Avatar::presence) dot for online status.
7//! [`avatar_group`] overlaps several avatars and counts the ones left over.
8//!
9//! Images are drawn from an [`image::Handle`]. Handles built from pixels
10//! (`Handle::from_rgba`) work as they are; to load PNG or JPEG files, enable
11//! iced's `image` feature in your app. iced's GPU renderer crops images to
12//! the avatar's shape; its software fallback draws them with square corners.
13
14use iced::widget::{Stack, container, image, space, text};
15use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
16
17use crate::icon::{Glyph, themed};
18use crate::lucide;
19use crate::natural::natural;
20use crate::theme::{Tokens, radius, semibold};
21
22/// Avatar dimensions.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
24pub enum Size {
25    Sm,
26    #[default]
27    Md,
28    Lg,
29    Xl,
30}
31
32impl Size {
33    pub const ALL: [Size; 4] = [Size::Sm, Size::Md, Size::Lg, Size::Xl];
34
35    /// Diameter, initials text size, icon size and presence dot diameter.
36    pub const fn metrics(self) -> Metrics {
37        match self {
38            Size::Sm => Metrics {
39                diameter: 24.0,
40                text: 10.0,
41                icon: 14.0,
42                dot: 8.0,
43            },
44            Size::Md => Metrics {
45                diameter: 32.0,
46                text: 12.0,
47                icon: 16.0,
48                dot: 10.0,
49            },
50            Size::Lg => Metrics {
51                diameter: 40.0,
52                text: 14.0,
53                icon: 20.0,
54                dot: 12.0,
55            },
56            Size::Xl => Metrics {
57                diameter: 56.0,
58                text: 18.0,
59                icon: 28.0,
60                dot: 14.0,
61            },
62        }
63    }
64}
65
66/// Resolved dimensions for a [`Size`].
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct Metrics {
69    pub diameter: f32,
70    pub text: f32,
71    pub icon: f32,
72    pub dot: f32,
73}
74
75/// The outline of an avatar.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77pub enum Shape {
78    /// For people.
79    #[default]
80    Circle,
81    /// Rounded corners, for teams, organisations and bots.
82    Square,
83}
84
85impl Shape {
86    pub const ALL: [Shape; 2] = [Shape::Circle, Shape::Square];
87
88    /// The corner radius at a diameter.
89    pub fn radius(self, diameter: f32) -> f32 {
90        match self {
91            Shape::Circle => diameter / 2.0,
92            Shape::Square => (diameter / 5.0).clamp(radius::SM, radius::LG),
93        }
94    }
95}
96
97/// Whether someone is available, drawn as a dot on the avatar's corner.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub enum Presence {
100    Online,
101    Away,
102    Busy,
103    Offline,
104}
105
106impl Presence {
107    pub const ALL: [Presence; 4] = [
108        Presence::Online,
109        Presence::Away,
110        Presence::Busy,
111        Presence::Offline,
112    ];
113}
114
115/// Up to two initials for a name: the first letters of its first and last
116/// words, in upper case. One word gives one letter, and an empty name none.
117pub fn initials(name: &str) -> String {
118    let mut words = name
119        .split_whitespace()
120        .filter_map(|word| word.chars().find(|c| c.is_alphanumeric()));
121    let Some(first) = words.next() else {
122        return String::new();
123    };
124    let mut initials: String = first.to_uppercase().collect();
125    if let Some(last) = words.next_back() {
126        initials.extend(last.to_uppercase());
127    }
128    initials
129}
130
131/// An avatar builder. Convert it into an [`Element`] to render.
132#[derive(Debug, Clone)]
133pub struct Avatar {
134    initials: String,
135    image: Option<image::Handle>,
136    icon: Option<Glyph>,
137    size: Size,
138    shape: Shape,
139    presence: Option<Presence>,
140}
141
142/// Creates an avatar showing the [`initials`] of `name`. A name with no
143/// letters shows a person icon instead.
144pub fn avatar(name: impl AsRef<str>) -> Avatar {
145    Avatar {
146        initials: initials(name.as_ref()),
147        image: None,
148        icon: None,
149        size: Size::default(),
150        shape: Shape::default(),
151        presence: None,
152    }
153}
154
155impl Avatar {
156    /// Shows a picture instead of the initials, cropped to fill the shape.
157    pub fn image(mut self, handle: impl Into<image::Handle>) -> Self {
158        self.image = Some(handle.into());
159        self
160    }
161
162    /// Shows an icon instead of the initials, such as a bot for an
163    /// automated account. An image still takes precedence.
164    pub fn icon(mut self, glyph: Glyph) -> Self {
165        self.icon = Some(glyph);
166        self
167    }
168
169    pub fn size(mut self, size: Size) -> Self {
170        self.size = size;
171        self
172    }
173
174    pub fn shape(mut self, shape: Shape) -> Self {
175        self.shape = shape;
176        self
177    }
178
179    pub fn presence(mut self, presence: Presence) -> Self {
180        self.presence = Some(presence);
181        self
182    }
183
184    pub fn initials(&self) -> &str {
185        &self.initials
186    }
187
188    /// What the avatar shows.
189    pub fn content(&self) -> Content<'_> {
190        if let Some(handle) = &self.image {
191            return Content::Image(handle);
192        }
193        if let Some(glyph) = self.icon {
194            return Content::Icon(glyph);
195        }
196        if self.initials.is_empty() {
197            return Content::Icon(lucide!(User));
198        }
199        Content::Initials(&self.initials)
200    }
201}
202
203/// What an [`Avatar`] shows, in order of preference.
204#[derive(Debug, Clone, Copy, PartialEq)]
205pub enum Content<'a> {
206    Image(&'a image::Handle),
207    Icon(Glyph),
208    Initials(&'a str),
209}
210
211impl<'a, Message: 'a> From<Avatar> for Element<'a, Message> {
212    fn from(avatar: Avatar) -> Self {
213        render(avatar, false)
214    }
215}
216
217/// Draws an avatar, with a ring in the background colour when it overlaps
218/// others in a group.
219fn render<'a, Message: 'a>(avatar: Avatar, ring: bool) -> Element<'a, Message> {
220    let metrics = avatar.size.metrics();
221    let shape = avatar.shape;
222    let diameter = metrics.diameter;
223    let corner = shape.radius(diameter);
224
225    let face: Element<'a, Message> = match avatar.content() {
226        Content::Image(handle) => image(handle.clone())
227            .width(diameter)
228            .height(diameter)
229            .content_fit(iced::ContentFit::Cover)
230            .border_radius(corner)
231            .into(),
232        Content::Icon(glyph) => themed(glyph, metrics.icon, 1.0, |theme| {
233            Tokens::of(theme).muted_foreground
234        })
235        .into(),
236        Content::Initials(initials) => text(initials.to_owned())
237            .size(metrics.text)
238            .font(semibold())
239            .wrapping(text::Wrapping::None)
240            .into(),
241    };
242
243    let ring_width = if ring { RING } else { 0.0 };
244    let side = diameter + 2.0 * ring_width;
245    let face = container(face)
246        .center(Length::Fixed(side))
247        .padding(ring_width)
248        .style(move |theme: &Theme| style(&Tokens::of(theme), shape, diameter, ring));
249
250    // The outer container shrinks to its fixed-size content, so `natural`
251    // keeps the avatar whole in a narrower parent.
252    let Some(presence) = avatar.presence else {
253        return natural(container(face));
254    };
255    natural(container(
256        Stack::with_children([
257            face.into(),
258            container(dot(presence, metrics.dot))
259                .align_right(Length::Fixed(side))
260                .align_bottom(Length::Fixed(side))
261                .into(),
262        ])
263        .width(side)
264        .height(side),
265    ))
266}
267
268fn dot<'a, Message: 'a>(presence: Presence, diameter: f32) -> Element<'a, Message> {
269    container(space())
270        .width(diameter)
271        .height(diameter)
272        .style(move |theme: &Theme| dot_style(&Tokens::of(theme), presence))
273        .into()
274}
275
276/// Width of the ring around an avatar in a group, in the background colour,
277/// so overlapping avatars stay apart. The ring and the presence dot's ring
278/// are drawn in the page background, so they suit avatars on the page
279/// rather than on a raised card.
280pub const RING: f32 = 2.0;
281
282/// The container style of an avatar: muted behind initials and icons, with
283/// a ring in the background colour when `ring` is set.
284pub fn style(tokens: &Tokens, shape: Shape, diameter: f32, ring: bool) -> container::Style {
285    let ring_width = if ring { RING } else { 0.0 };
286    container::Style {
287        text_color: Some(tokens.muted_foreground),
288        background: Some(Background::Color(tokens.muted)),
289        border: Border {
290            color: if ring {
291                tokens.background
292            } else {
293                Color::TRANSPARENT
294            },
295            width: ring_width,
296            radius: shape.radius(diameter + 2.0 * ring_width).into(),
297        },
298        ..container::Style::default()
299    }
300}
301
302/// The colour of a presence dot.
303pub fn presence_colour(tokens: &Tokens, presence: Presence) -> Color {
304    match presence {
305        Presence::Online => tokens.success,
306        Presence::Away => tokens.warning,
307        Presence::Busy => tokens.destructive,
308        Presence::Offline => tokens.muted_foreground,
309    }
310}
311
312/// The presence dot, ringed in the background colour so it stands off the
313/// picture behind it.
314pub fn dot_style(tokens: &Tokens, presence: Presence) -> container::Style {
315    container::Style {
316        background: Some(Background::Color(presence_colour(tokens, presence))),
317        border: Border {
318            color: tokens.background,
319            width: RING,
320            radius: radius::FULL.into(),
321        },
322        ..container::Style::default()
323    }
324}
325
326/// A row of overlapping avatars. Convert it into an [`Element`] to render.
327#[derive(Debug, Clone)]
328pub struct AvatarGroup {
329    avatars: Vec<Avatar>,
330    size: Size,
331    max: Option<usize>,
332}
333
334/// Overlaps `avatars` from left to right, each one ringed in the
335/// background colour.
336pub fn avatar_group(avatars: impl IntoIterator<Item = Avatar>) -> AvatarGroup {
337    AvatarGroup {
338        avatars: avatars.into_iter().collect(),
339        size: Size::default(),
340        max: None,
341    }
342}
343
344impl AvatarGroup {
345    /// The size of every avatar in the group, replacing their own.
346    pub fn size(mut self, size: Size) -> Self {
347        self.size = size;
348        self
349    }
350
351    /// Shows at most `max` avatars. When there are more, the last place
352    /// shows a count of the rest, such as "+3", so the group never takes
353    /// more than `max` places.
354    pub fn max_items(mut self, max: usize) -> Self {
355        self.max = Some(max);
356        self
357    }
358
359    /// How many avatars are shown, and how many the count stands in for.
360    pub fn visible(&self) -> (usize, usize) {
361        overflow(self.avatars.len(), self.max)
362    }
363}
364
365/// Splits `len` avatars into the number shown and the number counted, for
366/// at most `max` places. The count takes one of the places, and at least
367/// one avatar is always shown.
368pub fn overflow(len: usize, max: Option<usize>) -> (usize, usize) {
369    let Some(max) = max else {
370        return (len, 0);
371    };
372    let max = max.max(2);
373    if len <= max {
374        return (len, 0);
375    }
376    (max - 1, len - (max - 1))
377}
378
379/// How far each avatar in a group sits from the one before it: a fifth of
380/// the face is covered, plus the ring, so initials stay readable.
381pub fn step(size: Size) -> f32 {
382    (size.metrics().diameter * 0.8 + RING).round()
383}
384
385impl<'a, Message: 'a> From<AvatarGroup> for Element<'a, Message> {
386    fn from(group: AvatarGroup) -> Self {
387        let (shown, rest) = group.visible();
388        let size = group.size;
389        let metrics = size.metrics();
390        let side = metrics.diameter + 2.0 * RING;
391        let step = step(size);
392
393        let mut faces: Vec<Element<'a, Message>> = group
394            .avatars
395            .into_iter()
396            .take(shown)
397            .map(|avatar| render(avatar.size(size), true))
398            .collect();
399        if rest > 0 {
400            faces.push(count(rest, size));
401        }
402
403        let places = faces.len();
404        let width = if places == 0 {
405            0.0
406        } else {
407            side + step * (places - 1) as f32
408        };
409        let layers = faces.into_iter().enumerate().map(|(index, face)| {
410            container(face)
411                .padding(Padding::ZERO.left(step * index as f32))
412                .into()
413        });
414        natural(container(
415            Stack::with_children(layers).width(width).height(side),
416        ))
417    }
418}
419
420/// The "+N" place at the end of a group.
421fn count<'a, Message: 'a>(rest: usize, size: Size) -> Element<'a, Message> {
422    let metrics = size.metrics();
423    let side = metrics.diameter + 2.0 * RING;
424    container(
425        text(format!("+{rest}"))
426            .size(metrics.text)
427            .font(semibold())
428            .wrapping(text::Wrapping::None)
429            .align_x(Alignment::Center),
430    )
431    .center(Length::Fixed(side))
432    .padding(RING)
433    .style(move |theme: &Theme| style(&Tokens::of(theme), Shape::Circle, metrics.diameter, true))
434    .into()
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::theme::{dark, light};
441
442    #[test]
443    fn initials_take_the_first_and_last_words() {
444        assert_eq!(initials("Ada Lovelace"), "AL");
445        assert_eq!(initials("grace brewster murray hopper"), "GH");
446        assert_eq!(initials("  Linus  "), "L");
447        assert_eq!(initials("émile zola"), "ÉZ");
448        assert_eq!(initials("(Ops) Team"), "OT");
449        assert_eq!(initials(""), "");
450        assert_eq!(initials("   "), "");
451        assert_eq!(initials("- -"), "");
452    }
453
454    #[test]
455    fn builder_defaults() {
456        let a = avatar("Ada Lovelace");
457        assert_eq!(a.initials(), "AL");
458        assert_eq!(a.size, Size::Md);
459        assert_eq!(a.shape, Shape::Circle);
460        assert_eq!(a.presence, None);
461        assert_eq!(a.content(), Content::Initials("AL"));
462    }
463
464    #[test]
465    fn content_prefers_image_then_icon_then_initials() {
466        let handle = image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
467        let bot = lucide!(Bot);
468        let a = avatar("Build bot").icon(bot);
469        assert_eq!(a.content(), Content::Icon(bot));
470        let a = a.image(handle.clone());
471        assert_eq!(a.content(), Content::Image(&handle));
472        assert_eq!(avatar("").content(), Content::Icon(lucide!(User)));
473    }
474
475    #[test]
476    fn builder_sets_size_shape_and_presence() {
477        let a = avatar("Ops")
478            .size(Size::Xl)
479            .shape(Shape::Square)
480            .presence(Presence::Busy);
481        assert_eq!(a.size, Size::Xl);
482        assert_eq!(a.shape, Shape::Square);
483        assert_eq!(a.presence, Some(Presence::Busy));
484    }
485
486    #[test]
487    fn sizes_grow_monotonically() {
488        let metrics = Size::ALL.map(Size::metrics);
489        for pair in metrics.windows(2) {
490            assert!(pair[0].diameter < pair[1].diameter);
491            assert!(pair[0].text < pair[1].text);
492            assert!(pair[0].dot < pair[1].dot);
493        }
494    }
495
496    #[test]
497    fn circles_are_round_and_squares_have_modest_corners() {
498        for size in Size::ALL {
499            let diameter = size.metrics().diameter;
500            assert_eq!(Shape::Circle.radius(diameter), diameter / 2.0);
501            let square = Shape::Square.radius(diameter);
502            assert!((radius::SM..=radius::LG).contains(&square), "{size:?}");
503        }
504    }
505
506    #[test]
507    fn avatars_sit_on_the_muted_surface_and_ring_only_in_groups() {
508        for theme in [light(), dark()] {
509            let tokens = Tokens::of(&theme);
510            for shape in Shape::ALL {
511                let alone = style(&tokens, shape, 32.0, false);
512                let ringed = style(&tokens, shape, 32.0, true);
513                assert_eq!(alone.background, Some(Background::Color(tokens.muted)));
514                assert_eq!(alone.text_color, Some(tokens.muted_foreground));
515                assert_eq!(alone.border.width, 0.0);
516                assert_eq!(ringed.border.width, RING);
517                assert_eq!(ringed.border.color, tokens.background);
518            }
519        }
520    }
521
522    #[test]
523    fn presence_uses_status_tokens() {
524        for theme in [light(), dark()] {
525            let tokens = Tokens::of(&theme);
526            assert_eq!(presence_colour(&tokens, Presence::Online), tokens.success);
527            assert_eq!(presence_colour(&tokens, Presence::Away), tokens.warning);
528            assert_eq!(presence_colour(&tokens, Presence::Busy), tokens.destructive);
529            assert_eq!(
530                presence_colour(&tokens, Presence::Offline),
531                tokens.muted_foreground
532            );
533            for presence in Presence::ALL {
534                let style = dot_style(&tokens, presence);
535                assert_eq!(style.border.color, tokens.background);
536                assert_eq!(style.border.radius, radius::FULL.into());
537            }
538        }
539    }
540
541    #[test]
542    fn overflow_keeps_the_group_within_its_places() {
543        assert_eq!(overflow(3, None), (3, 0));
544        assert_eq!(overflow(3, Some(3)), (3, 0));
545        assert_eq!(overflow(5, Some(3)), (2, 3));
546        assert_eq!(overflow(5, Some(0)), (1, 4));
547        assert_eq!(overflow(0, Some(3)), (0, 0));
548        for len in 0..10 {
549            for max in 2..6 {
550                let (shown, rest) = overflow(len, Some(max));
551                assert_eq!(shown + rest, len);
552                assert!(shown + usize::from(rest > 0) <= max);
553            }
554        }
555    }
556
557    #[test]
558    fn group_avatars_overlap_by_a_fifth_of_their_face() {
559        for size in Size::ALL {
560            let diameter = size.metrics().diameter;
561            let step = step(size);
562            assert!(step < diameter + 2.0 * RING, "{size:?} overlaps");
563            assert!(step >= diameter * 0.8, "{size:?} keeps initials clear");
564        }
565    }
566
567    #[test]
568    fn group_builder() {
569        let group = avatar_group(["A", "B", "C", "D"].map(avatar))
570            .size(Size::Sm)
571            .max_items(3);
572        assert_eq!(group.size, Size::Sm);
573        assert_eq!(group.visible(), (2, 2));
574        assert_eq!(avatar_group([avatar("A")]).visible(), (1, 0));
575    }
576}