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, mix, 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 fill behind initials and icons: the muted surface, lifted a little
283/// in dark themes, where muted sits too close to the background for an
284/// avatar's edge, or the ring between overlapping avatars, to show.
285pub fn fill(tokens: &Tokens) -> Color {
286    if tokens.is_dark {
287        mix(tokens.muted, tokens.foreground, DARK_LIFT)
288    } else {
289        tokens.muted
290    }
291}
292
293const DARK_LIFT: f32 = 0.06;
294
295/// The container style of an avatar: the [`fill`] behind initials and
296/// icons, with a ring in the background colour when `ring` is set.
297pub fn style(tokens: &Tokens, shape: Shape, diameter: f32, ring: bool) -> container::Style {
298    let ring_width = if ring { RING } else { 0.0 };
299    container::Style {
300        text_color: Some(tokens.muted_foreground),
301        background: Some(Background::Color(fill(tokens))),
302        border: Border {
303            color: if ring {
304                tokens.background
305            } else {
306                Color::TRANSPARENT
307            },
308            width: ring_width,
309            radius: shape.radius(diameter + 2.0 * ring_width).into(),
310        },
311        ..container::Style::default()
312    }
313}
314
315/// The colour of a presence dot.
316pub fn presence_colour(tokens: &Tokens, presence: Presence) -> Color {
317    match presence {
318        Presence::Online => tokens.success,
319        Presence::Away => tokens.warning,
320        Presence::Busy => tokens.destructive,
321        Presence::Offline => tokens.muted_foreground,
322    }
323}
324
325/// The presence dot, ringed in the background colour so it stands off the
326/// picture behind it.
327pub fn dot_style(tokens: &Tokens, presence: Presence) -> container::Style {
328    container::Style {
329        background: Some(Background::Color(presence_colour(tokens, presence))),
330        border: Border {
331            color: tokens.background,
332            width: RING,
333            radius: radius::FULL.into(),
334        },
335        ..container::Style::default()
336    }
337}
338
339/// A row of overlapping avatars. Convert it into an [`Element`] to render.
340#[derive(Debug, Clone)]
341pub struct AvatarGroup {
342    avatars: Vec<Avatar>,
343    size: Size,
344    max: Option<usize>,
345}
346
347/// Overlaps `avatars` from left to right, each one ringed in the
348/// background colour.
349pub fn avatar_group(avatars: impl IntoIterator<Item = Avatar>) -> AvatarGroup {
350    AvatarGroup {
351        avatars: avatars.into_iter().collect(),
352        size: Size::default(),
353        max: None,
354    }
355}
356
357impl AvatarGroup {
358    /// The size of every avatar in the group, replacing their own.
359    pub fn size(mut self, size: Size) -> Self {
360        self.size = size;
361        self
362    }
363
364    /// Shows at most `max` avatars. When there are more, the last place
365    /// shows a count of the rest, such as "+3", so the group never takes
366    /// more than `max` places.
367    pub fn max_items(mut self, max: usize) -> Self {
368        self.max = Some(max);
369        self
370    }
371
372    /// How many avatars are shown, and how many the count stands in for.
373    pub fn visible(&self) -> (usize, usize) {
374        overflow(self.avatars.len(), self.max)
375    }
376}
377
378/// Splits `len` avatars into the number shown and the number counted, for
379/// at most `max` places. The count takes one of the places, and at least
380/// one avatar is always shown.
381pub fn overflow(len: usize, max: Option<usize>) -> (usize, usize) {
382    let Some(max) = max else {
383        return (len, 0);
384    };
385    let max = max.max(2);
386    if len <= max {
387        return (len, 0);
388    }
389    (max - 1, len - (max - 1))
390}
391
392/// How far each avatar in a group sits from the one before it: a fifth of
393/// the face is covered, plus the ring, so initials stay readable.
394pub fn step(size: Size) -> f32 {
395    (size.metrics().diameter * 0.8 + RING).round()
396}
397
398impl<'a, Message: 'a> From<AvatarGroup> for Element<'a, Message> {
399    fn from(group: AvatarGroup) -> Self {
400        let (shown, rest) = group.visible();
401        let size = group.size;
402        let metrics = size.metrics();
403        let side = metrics.diameter + 2.0 * RING;
404        let step = step(size);
405
406        let mut faces: Vec<Element<'a, Message>> = group
407            .avatars
408            .into_iter()
409            .take(shown)
410            .map(|avatar| render(avatar.size(size), true))
411            .collect();
412        if rest > 0 {
413            faces.push(count(rest, size));
414        }
415
416        let places = faces.len();
417        let width = if places == 0 {
418            0.0
419        } else {
420            side + step * (places - 1) as f32
421        };
422        let layers = faces.into_iter().enumerate().map(|(index, face)| {
423            container(face)
424                .padding(Padding::ZERO.left(step * index as f32))
425                .into()
426        });
427        natural(container(
428            Stack::with_children(layers).width(width).height(side),
429        ))
430    }
431}
432
433/// The "+N" place at the end of a group.
434fn count<'a, Message: 'a>(rest: usize, size: Size) -> Element<'a, Message> {
435    let metrics = size.metrics();
436    let side = metrics.diameter + 2.0 * RING;
437    container(
438        text(format!("+{rest}"))
439            .size(metrics.text)
440            .font(semibold())
441            .wrapping(text::Wrapping::None)
442            .align_x(Alignment::Center),
443    )
444    .center(Length::Fixed(side))
445    .padding(RING)
446    .style(move |theme: &Theme| style(&Tokens::of(theme), Shape::Circle, metrics.diameter, true))
447    .into()
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::theme::{dark, light};
454
455    #[test]
456    fn initials_take_the_first_and_last_words() {
457        assert_eq!(initials("Ada Lovelace"), "AL");
458        assert_eq!(initials("grace brewster murray hopper"), "GH");
459        assert_eq!(initials("  Linus  "), "L");
460        assert_eq!(initials("émile zola"), "ÉZ");
461        assert_eq!(initials("(Ops) Team"), "OT");
462        assert_eq!(initials(""), "");
463        assert_eq!(initials("   "), "");
464        assert_eq!(initials("- -"), "");
465    }
466
467    #[test]
468    fn builder_defaults() {
469        let a = avatar("Ada Lovelace");
470        assert_eq!(a.initials(), "AL");
471        assert_eq!(a.size, Size::Md);
472        assert_eq!(a.shape, Shape::Circle);
473        assert_eq!(a.presence, None);
474        assert_eq!(a.content(), Content::Initials("AL"));
475    }
476
477    #[test]
478    fn content_prefers_image_then_icon_then_initials() {
479        let handle = image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
480        let bot = lucide!(Bot);
481        let a = avatar("Build bot").icon(bot);
482        assert_eq!(a.content(), Content::Icon(bot));
483        let a = a.image(handle.clone());
484        assert_eq!(a.content(), Content::Image(&handle));
485        assert_eq!(avatar("").content(), Content::Icon(lucide!(User)));
486    }
487
488    #[test]
489    fn builder_sets_size_shape_and_presence() {
490        let a = avatar("Ops")
491            .size(Size::Xl)
492            .shape(Shape::Square)
493            .presence(Presence::Busy);
494        assert_eq!(a.size, Size::Xl);
495        assert_eq!(a.shape, Shape::Square);
496        assert_eq!(a.presence, Some(Presence::Busy));
497    }
498
499    #[test]
500    fn sizes_grow_monotonically() {
501        let metrics = Size::ALL.map(Size::metrics);
502        for pair in metrics.windows(2) {
503            assert!(pair[0].diameter < pair[1].diameter);
504            assert!(pair[0].text < pair[1].text);
505            assert!(pair[0].dot < pair[1].dot);
506        }
507    }
508
509    #[test]
510    fn circles_are_round_and_squares_have_modest_corners() {
511        for size in Size::ALL {
512            let diameter = size.metrics().diameter;
513            assert_eq!(Shape::Circle.radius(diameter), diameter / 2.0);
514            let square = Shape::Square.radius(diameter);
515            assert!((radius::SM..=radius::LG).contains(&square), "{size:?}");
516        }
517    }
518
519    #[test]
520    fn avatars_sit_on_the_muted_surface_and_ring_only_in_groups() {
521        for theme in [light(), dark()] {
522            let tokens = Tokens::of(&theme);
523            for shape in Shape::ALL {
524                let alone = style(&tokens, shape, 32.0, false);
525                let ringed = style(&tokens, shape, 32.0, true);
526                assert_eq!(alone.background, Some(Background::Color(fill(&tokens))));
527                assert_eq!(alone.text_color, Some(tokens.muted_foreground));
528                assert_eq!(alone.border.width, 0.0);
529                assert_eq!(ringed.border.width, RING);
530                assert_eq!(ringed.border.color, tokens.background);
531            }
532        }
533    }
534
535    fn lightness(colour: Color) -> f32 {
536        colour.r + colour.g + colour.b
537    }
538
539    #[test]
540    fn the_ring_stands_out_against_the_fill_in_both_themes() {
541        for theme in [light(), dark()] {
542            let tokens = Tokens::of(&theme);
543            let ringed = style(&tokens, Shape::Circle, 32.0, true);
544            assert_eq!(ringed.border.color, tokens.background);
545            let gap = (lightness(fill(&tokens)) - lightness(tokens.background)).abs();
546            assert!(gap > 0.35, "dark: {} gap {gap}", tokens.is_dark);
547        }
548    }
549
550    #[test]
551    fn presence_uses_status_tokens() {
552        for theme in [light(), dark()] {
553            let tokens = Tokens::of(&theme);
554            assert_eq!(presence_colour(&tokens, Presence::Online), tokens.success);
555            assert_eq!(presence_colour(&tokens, Presence::Away), tokens.warning);
556            assert_eq!(presence_colour(&tokens, Presence::Busy), tokens.destructive);
557            assert_eq!(
558                presence_colour(&tokens, Presence::Offline),
559                tokens.muted_foreground
560            );
561            for presence in Presence::ALL {
562                let style = dot_style(&tokens, presence);
563                assert_eq!(style.border.color, tokens.background);
564                assert_eq!(style.border.radius, radius::FULL.into());
565            }
566        }
567    }
568
569    #[test]
570    fn overflow_keeps_the_group_within_its_places() {
571        assert_eq!(overflow(3, None), (3, 0));
572        assert_eq!(overflow(3, Some(3)), (3, 0));
573        assert_eq!(overflow(5, Some(3)), (2, 3));
574        assert_eq!(overflow(5, Some(0)), (1, 4));
575        assert_eq!(overflow(0, Some(3)), (0, 0));
576        for len in 0..10 {
577            for max in 2..6 {
578                let (shown, rest) = overflow(len, Some(max));
579                assert_eq!(shown + rest, len);
580                assert!(shown + usize::from(rest > 0) <= max);
581            }
582        }
583    }
584
585    #[test]
586    fn group_avatars_overlap_by_a_fifth_of_their_face() {
587        for size in Size::ALL {
588            let diameter = size.metrics().diameter;
589            let step = step(size);
590            assert!(step < diameter + 2.0 * RING, "{size:?} overlaps");
591            assert!(step >= diameter * 0.8, "{size:?} keeps initials clear");
592        }
593    }
594
595    #[test]
596    fn group_builder() {
597        let group = avatar_group(["A", "B", "C", "D"].map(avatar))
598            .size(Size::Sm)
599            .max_items(3);
600        assert_eq!(group.size, Size::Sm);
601        assert_eq!(group.visible(), (2, 2));
602        assert_eq!(avatar_group([avatar("A")]).visible(), (1, 0));
603    }
604}