Skip to main content

gpui_kit_material/components/
avatar.rs

1//! Avatars: a person's image, monogram or placeholder in a circle.
2//!
3//! Geometry and color come from the Figma Material 3 Design Kit's "Avatars"
4//! page (`Generic avatar` component set): a 40dp circle on
5//! primary-container with on-primary-container content. The monogram is
6//! title-medium (16/24, +0.1 tracking); the placeholder is the kit's person
7//! glyph (28×26 in 40dp) and the selected style a 24dp check. Other sizes
8//! scale every proportion with the diameter. Image avatars fill the circle
9//! (cover), over the container color while the image loads.
10//!
11//! Avatars are not interactive: the caller wraps one in a button when it
12//! acts (e.g. an account menu). The caller supplies the accessible name.
13use crate::{
14    color::ColorRole,
15    icons::{Icon, Symbol},
16    theme::color,
17    tokens::{
18        state,
19        typography::{TypeRole, rem},
20    },
21};
22use gpui::{prelude::*, *};
23
24/// The kit's avatar diameter in design px.
25pub const AVATAR_SIZE: f32 = 40.;
26
27/// What an avatar shows.
28// A short-lived builder value consumed by one render, like `ListLeading`;
29// boxing the icon would only complicate callers.
30#[allow(clippy::large_enum_variant)]
31#[derive(Clone)]
32pub enum AvatarContent {
33    /// The kit's "Avatar" style: the person placeholder glyph.
34    Placeholder,
35    /// The kit's "Monogram" style: one or two initials.
36    Monogram(SharedString),
37    /// Any Material Symbol, drawn like the placeholder.
38    Icon(Icon),
39    /// A user image, cropped to the circle.
40    Image(ImageSource),
41}
42
43/// A circular avatar. See the module documentation.
44#[derive(gpui::IntoElement)]
45pub struct Avatar {
46    id: ElementId,
47    label: SharedString,
48    content: AvatarContent,
49    size: f32,
50    selected: bool,
51    disabled: bool,
52}
53
54impl Avatar {
55    /// A placeholder avatar named `label` for assistive technology.
56    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
57        Self {
58            id: id.into(),
59            label: label.into(),
60            content: AvatarContent::Placeholder,
61            size: AVATAR_SIZE,
62            selected: false,
63            disabled: false,
64        }
65    }
66    pub fn content(mut self, content: AvatarContent) -> Self {
67        self.content = content;
68        self
69    }
70    /// Show initials (the first two characters are drawn).
71    pub fn monogram(self, initials: impl Into<SharedString>) -> Self {
72        self.content(AvatarContent::Monogram(initials.into()))
73    }
74    pub fn icon(self, icon: impl Into<Icon>) -> Self {
75        self.content(AvatarContent::Icon(icon.into()))
76    }
77    pub fn image(self, source: impl Into<ImageSource>) -> Self {
78        self.content(AvatarContent::Image(source.into()))
79    }
80    /// The diameter in design px (rems at the base text scale). Nonfinite or
81    /// non-positive sizes use the kit's 40.
82    pub fn size(mut self, size: f32) -> Self {
83        self.size = if size.is_finite() && size > 0. {
84            size
85        } else {
86            AVATAR_SIZE
87        };
88        self
89    }
90    /// The kit's "Check" style, e.g. a selected item in a list.
91    pub fn selected(mut self, selected: bool) -> Self {
92        self.selected = selected;
93        self
94    }
95    /// Paint the M3 disabled container and content (on-surface at 12% and
96    /// 38%). Images fade to 38%.
97    pub fn disabled(mut self, disabled: bool) -> Self {
98        self.disabled = disabled;
99        self
100    }
101
102    /// The circle's and the content's colors.
103    pub fn colors(&self) -> (Rgba, Rgba) {
104        if self.disabled {
105            let on_surface = color(ColorRole::ON_SURFACE);
106            (
107                on_surface.opacity(state::DISABLED_CONTAINER),
108                on_surface.opacity(state::DISABLED_CONTENT),
109            )
110        } else {
111            (
112                color(ColorRole::PRIMARY_CONTAINER),
113                color(ColorRole::ON_PRIMARY_CONTAINER),
114            )
115        }
116    }
117}
118
119/// The initials a monogram draws: at most two characters, uppercased.
120pub fn monogram_initials(text: &str) -> SharedString {
121    text.trim()
122        .chars()
123        .filter(|c| !c.is_whitespace())
124        .take(2)
125        .flat_map(char::to_uppercase)
126        .collect::<String>()
127        .into()
128}
129
130fn symbol(name: &str) -> Icon {
131    Icon::from(Symbol::named(name).expect("a Material Symbol in the pinned catalog"))
132}
133
134impl RenderOnce for Avatar {
135    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
136        let (container, ink) = self.colors();
137        let size = self.size;
138        // Every proportion scales with the diameter from the 40dp kit frame.
139        let scale = size / AVATAR_SIZE;
140        let glyph = |icon: Icon, design: f32| icon.size(rem(design * scale)).color(ink);
141        let content: AnyElement = if self.selected {
142            glyph(symbol("check"), 24.).into_any_element()
143        } else {
144            match self.content {
145                AvatarContent::Placeholder => glyph(symbol("person"), 28.).into_any_element(),
146                AvatarContent::Icon(icon) => glyph(icon, 24.).into_any_element(),
147                AvatarContent::Monogram(text) => {
148                    let type_scale = TypeRole::TitleMedium.scale();
149                    div()
150                        .font_family(type_scale.font_family)
151                        .font_weight(FontWeight(type_scale.font_weight as f32))
152                        .text_size(rem(16. * scale))
153                        .line_height(rem(24. * scale))
154                        .text_color(ink)
155                        .child(monogram_initials(&text))
156                        .into_any_element()
157                }
158                AvatarContent::Image(source) => img(source)
159                    .size_full()
160                    .object_fit(ObjectFit::Cover)
161                    .rounded_full()
162                    .when(self.disabled, |image| {
163                        image.opacity(state::DISABLED_CONTENT)
164                    })
165                    .into_any_element(),
166            }
167        };
168        div()
169            .id(self.id)
170            .role(Role::Image)
171            .aria_label(self.label)
172            .flex()
173            .flex_shrink_0()
174            .items_center()
175            .justify_center()
176            .size(rem(size))
177            .rounded_full()
178            .overflow_hidden()
179            .bg(container)
180            .child(content)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use core::prelude::v1::test;
188
189    #[test]
190    fn monograms_draw_two_uppercase_initials() {
191        assert_eq!(monogram_initials("a"), "A");
192        assert_eq!(monogram_initials(" jd "), "JD");
193        assert_eq!(monogram_initials("j d x"), "JD");
194        assert_eq!(monogram_initials("ßa"), "SSA");
195        assert_eq!(monogram_initials(""), "");
196    }
197
198    #[test]
199    fn sizes_fall_back_to_the_kit_diameter() {
200        assert_eq!(Avatar::new("a", "A").size, AVATAR_SIZE);
201        assert_eq!(Avatar::new("a", "A").size(56.).size, 56.);
202        assert_eq!(Avatar::new("a", "A").size(0.).size, AVATAR_SIZE);
203        assert_eq!(Avatar::new("a", "A").size(f32::NAN).size, AVATAR_SIZE);
204    }
205
206    #[test]
207    fn colors_follow_primary_container_and_disabled_roles() {
208        let (container, ink) = Avatar::new("a", "A").colors();
209        assert_eq!(container, color(ColorRole::PRIMARY_CONTAINER));
210        assert_eq!(ink, color(ColorRole::ON_PRIMARY_CONTAINER));
211        let (container, ink) = Avatar::new("a", "A").disabled(true).colors();
212        let on_surface = color(ColorRole::ON_SURFACE);
213        assert_eq!(container, on_surface.opacity(state::DISABLED_CONTAINER));
214        assert_eq!(ink, on_surface.opacity(state::DISABLED_CONTENT));
215    }
216}