Skip to main content

frus_widgets/
avatar.rs

1//! [`CircleAvatar`]: a circle that stands for a user — their picture, or in its absence
2//! their initials, on a colour (milestone 647, the reference's `CircleAvatar`).
3
4use frus_core::{
5    BoxDecoration, BoxFit, BoxShape, Color, DecorationImage, ImageHandle, Rect, Scene, Size,
6};
7use frus_layout::{Align, Dimension, Justify, Style};
8
9use crate::interaction::Status;
10use crate::theme::Theme;
11use crate::widget::Widget;
12
13/// The radius when none is given: the size a list tile's leading slot is made for.
14pub const AVATAR_RADIUS: f32 = 20.0;
15
16/// How long a change of colour or size takes to arrive — the reference's
17/// `kThemeChangeDuration`.
18const CHANGE: f32 = 0.2;
19
20/// A circle that represents a user: a picture cropped to the circle, or, without one, a
21/// child — usually their initials — centred on a colour.
22///
23/// ```ignore
24/// CircleAvatar::initials("Ada Lovelace")                       // "AL" on the theme's colour
25/// CircleAvatar::new().child(Icon::new(icons::PERSON))          // any widget
26/// CircleAvatar::new().background_image(photo).radius(32.0)     // a picture
27/// ```
28///
29/// The defaults are the reference's: a radius of 20, the scheme's `primary_container`
30/// behind and `on_primary_container` in front; the child's text in the theme's
31/// `title_medium`, its icons in the same colour, and its text not scaled with the reader's
32/// font size, so it does not spill out of a circle that did not grow. A change of colour
33/// or of radius is animated.
34///
35/// The **foreground** picture is painted over the child and the **background** one under
36/// it. A foreground picture still on its way — or that never arrives — leaves the
37/// background showing, and a background one the colour, which is how the reference falls
38/// back from one to the next.
39pub struct CircleAvatar<Msg = crate::callback::Callback> {
40    child: Vec<Box<dyn Widget<Msg>>>,
41    background_color: Option<Color>,
42    foreground_color: Option<Color>,
43    background_image: Option<ImageHandle>,
44    foreground_image: Option<ImageHandle>,
45    radius: Option<f32>,
46    min_radius: Option<f32>,
47    max_radius: Option<f32>,
48}
49
50impl<Msg> Default for CircleAvatar<Msg> {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl<Msg> CircleAvatar<Msg> {
57    /// An empty avatar: the colour alone, at the default radius.
58    pub fn new() -> Self {
59        Self {
60            child: Vec::new(),
61            background_color: None,
62            foreground_color: None,
63            background_image: None,
64            foreground_image: None,
65            radius: None,
66            min_radius: None,
67            max_radius: None,
68        }
69    }
70
71    /// An avatar showing the **initials** of `name`: the first letters of its first two
72    /// words, upper-cased, or its first two letters when it is one word.
73    pub fn initials(name: impl Into<String>) -> Self
74    where
75        Msg: Clone + 'static,
76    {
77        Self::new().child(crate::text(initials_of(&name.into())))
78    }
79
80    /// What is shown in the circle, centred: usually a text, or an icon.
81    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
82        self.child = vec![Box::new(child)];
83        self
84    }
85
86    /// The colour the circle is filled with; the scheme's `primary_container` otherwise.
87    pub fn background_color(mut self, color: Color) -> Self {
88        self.background_color = Some(color);
89        self
90    }
91
92    /// The colour of the child's text and icons; the scheme's `on_primary_container`
93    /// otherwise.
94    pub fn foreground_color(mut self, color: Color) -> Self {
95        self.foreground_color = Some(color);
96        self
97    }
98
99    /// A picture under the child, covering the circle: the fallback for
100    /// [`Self::foreground_image`]. `None` — a picture not yet arrived — shows the colour.
101    pub fn background_image(mut self, image: impl Into<Option<ImageHandle>>) -> Self {
102        self.background_image = image.into();
103        self
104    }
105
106    /// A picture over the child, covering the circle: usually the user's photo.
107    pub fn foreground_image(mut self, image: impl Into<Option<ImageHandle>>) -> Self {
108        self.foreground_image = image.into();
109        self
110    }
111
112    /// The radius, in logical pixels: the avatar is this big, whatever it holds.
113    pub fn radius(mut self, radius: f32) -> Self {
114        self.radius = Some(radius);
115        self
116    }
117
118    /// The smallest radius it may have; nought by default once a bound is set.
119    pub fn min_radius(mut self, radius: f32) -> Self {
120        self.min_radius = Some(radius);
121        self
122    }
123
124    /// The largest radius it may have; unbounded by default once a bound is set.
125    pub fn max_radius(mut self, radius: f32) -> Self {
126        self.max_radius = Some(radius);
127        self
128    }
129
130    /// The size as a **diameter**: [`Self::radius`] of half of it.
131    pub fn size(self, diameter: f32) -> Self {
132        self.radius(diameter * 0.5)
133    }
134
135    /// No bound at all: the reference's default radius on both sides.
136    fn unbounded(&self) -> bool {
137        self.radius.is_none() && self.min_radius.is_none() && self.max_radius.is_none()
138    }
139
140    /// The smallest diameter, the reference's `_minDiameter`.
141    fn min_diameter(&self) -> f32 {
142        if self.unbounded() {
143            return AVATAR_RADIUS * 2.0;
144        }
145        2.0 * self.radius.or(self.min_radius).unwrap_or(0.0)
146    }
147
148    /// The largest diameter, the reference's `_maxDiameter`.
149    fn max_diameter(&self) -> f32 {
150        if self.unbounded() {
151            return AVATAR_RADIUS * 2.0;
152        }
153        2.0 * self.radius.or(self.max_radius).unwrap_or(f32::INFINITY)
154    }
155
156    /// The size it has whatever it holds, when its two bounds agree: what is animated.
157    fn fixed(&self) -> Option<f32> {
158        let (lo, hi) = (self.min_diameter(), self.max_diameter());
159        (lo == hi).then_some(lo)
160    }
161
162    fn colors(&self, theme: &Theme) -> (Color, Color) {
163        (
164            self.background_color
165                .unwrap_or(theme.scheme.primary_container),
166            self.foreground_color
167                .unwrap_or(theme.scheme.on_primary_container),
168        )
169    }
170}
171
172/// The initials of `name`: the first letters of its first two words, upper-cased, or its
173/// first two letters when it has no spaces to split at.
174fn initials_of(name: &str) -> String {
175    let initials: String = name
176        .split_whitespace()
177        .filter_map(|word| word.chars().next())
178        .take(2)
179        .collect::<String>()
180        .to_uppercase();
181    if initials.is_empty() {
182        name.chars().take(2).collect::<String>().to_uppercase()
183    } else {
184        initials
185    }
186}
187
188/// A picture covering the circle.
189fn covering(image: &ImageHandle) -> DecorationImage {
190    DecorationImage::new(image.clone()).fit(BoxFit::Cover)
191}
192
193impl<Msg> Widget<Msg> for CircleAvatar<Msg> {
194    fn style(&self) -> Style {
195        let fixed = self.fixed();
196        let (lo, hi) = (self.min_diameter(), self.max_diameter());
197        let bound = |d: f32| {
198            if d.is_finite() {
199                Dimension::Length(d)
200            } else {
201                Dimension::Auto
202            }
203        };
204        Style {
205            width: fixed.map_or(Dimension::Auto, Dimension::Length),
206            height: fixed.map_or(Dimension::Auto, Dimension::Length),
207            min_width: Dimension::Length(lo),
208            min_height: Dimension::Length(lo),
209            max_width: bound(hi),
210            max_height: bound(hi),
211            // The child in the middle, as the reference's `Center`.
212            justify: Justify::Center,
213            align: Align::Center,
214            ..Default::default()
215        }
216    }
217
218    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
219        &self.child
220    }
221
222    fn debug_name(&self) -> &'static str {
223        "CircleAvatar"
224    }
225
226    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
227        let (background, _) = self.colors(theme);
228        let background = status.anim_color.unwrap_or(background);
229        let mut decoration = BoxDecoration::filled(background).shape(BoxShape::Circle);
230        if let Some(image) = &self.background_image {
231            decoration = decoration.image(covering(image));
232        }
233        decoration.paint_into_in(scene, bounds, status.opacity, theme.direction);
234    }
235
236    fn foreground(&self, _theme: &Theme) -> Option<BoxDecoration> {
237        self.foreground_image.as_ref().map(|image| {
238            BoxDecoration::default()
239                .shape(BoxShape::Circle)
240                .image(covering(image))
241        })
242    }
243
244    /// The child's type and icons: the theme's `title_medium`, in the foreground colour.
245    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
246        if self.child.is_empty() {
247            return None;
248        }
249        let (_, foreground) = self.colors(inherited);
250        let mut theme = Box::new(inherited.clone());
251        theme.widgets.text = crate::DefaultTextStyle {
252            selectable: inherited.widgets.text.selectable,
253            ..crate::DefaultTextStyle::from_text_style(
254                inherited.text.title_medium.color(foreground),
255            )
256        };
257        theme.widgets.icon.color = Some(foreground);
258        Some(theme)
259    }
260
261    /// Its text is not scaled with the reader's font size: the circle does not grow.
262    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
263        (!self.child.is_empty()).then(|| inherited.with_text_scaler(1.0))
264    }
265
266    /// A colour given changes over a moment, as the reference's does.
267    fn anim_color(&self) -> Option<Color> {
268        self.background_color
269    }
270
271    /// A radius given changes over a moment too.
272    fn anim_size(&self) -> Option<Size> {
273        self.fixed()
274            .filter(|_| !self.unbounded())
275            .map(|d| Size::new(d, d))
276    }
277
278    fn anim_duration(&self) -> f32 {
279        CHANGE
280    }
281
282    fn on_click(&self) -> Option<Msg> {
283        None
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use frus_core::{ImageData, Primitive};
291
292    fn pixels() -> ImageHandle {
293        ImageData::from_rgba(2, 2, vec![255; 16]).into_handle()
294    }
295
296    fn painted(avatar: &CircleAvatar<()>, theme: &Theme) -> Vec<Primitive> {
297        let mut scene = Scene::new();
298        Widget::<()>::paint(
299            avatar,
300            Rect::new(0.0, 0.0, 40.0, 40.0),
301            Status::default(),
302            theme,
303            &mut scene,
304        );
305        scene.primitives().to_vec()
306    }
307
308    #[test]
309    fn takes_two_uppercased_initials() {
310        assert_eq!(initials_of("ada lovelace"), "AL");
311        assert_eq!(initials_of("bob"), "B");
312        assert_eq!(initials_of("grace brewster hopper"), "GB");
313        assert_eq!(initials_of(""), "");
314    }
315
316    /// **The reference's sizes**: a radius of 20 when nothing is said; a radius is both
317    /// bounds; one bound alone leaves the other at nought or unbounded.
318    #[test]
319    fn the_sizes_are_the_reference_s() {
320        let size = |a: CircleAvatar<()>| {
321            let s = Widget::<()>::style(&a);
322            (s.width, s.min_width, s.max_width)
323        };
324        let l = Dimension::Length;
325        assert_eq!(size(CircleAvatar::new()), (l(40.0), l(40.0), l(40.0)));
326        assert_eq!(
327            size(CircleAvatar::new().radius(12.0)),
328            (l(24.0), l(24.0), l(24.0))
329        );
330        assert_eq!(
331            size(CircleAvatar::new().size(30.0)),
332            (l(30.0), l(30.0), l(30.0))
333        );
334        assert_eq!(
335            size(CircleAvatar::new().min_radius(10.0)),
336            (Dimension::Auto, l(20.0), Dimension::Auto)
337        );
338        assert_eq!(
339            size(CircleAvatar::new().max_radius(30.0)),
340            (Dimension::Auto, l(0.0), l(60.0))
341        );
342        assert_eq!(
343            size(CircleAvatar::new().min_radius(10.0).max_radius(10.0)),
344            (l(20.0), l(20.0), l(20.0))
345        );
346        let s = Widget::<()>::style(&CircleAvatar::<()>::new());
347        assert_eq!((s.justify, s.align), (Justify::Center, Align::Center));
348    }
349
350    /// **The scheme's container colours**, unless given.
351    #[test]
352    fn the_colours_are_the_container_pair() {
353        let theme = Theme::default();
354        let Primitive::Rect { color, radius, .. } = &painted(&CircleAvatar::new(), &theme)[0]
355        else {
356            panic!("the circle");
357        };
358        assert_eq!(*color, theme.scheme.primary_container);
359        assert_eq!(radius.top_left, 20.0, "a circle");
360        let red = Color::rgb(1.0, 0.0, 0.0);
361        let Primitive::Rect { color, .. } =
362            &painted(&CircleAvatar::new().background_color(red), &theme)[0]
363        else {
364            panic!("the circle");
365        };
366        assert_eq!(*color, red);
367    }
368
369    /// **The child's type and icons** come from the avatar: `title_medium` in the
370    /// foreground colour, and no text scaling; with no child there is nothing to style.
371    #[test]
372    fn the_child_takes_the_title_style_in_the_foreground_colour() {
373        let theme = Theme::default();
374        let avatar = CircleAvatar::<()>::initials("Ada");
375        let inner = Widget::<()>::theme_override(&avatar, &theme).expect("a theme for the child");
376        let style = inner.widgets.text.style;
377        assert_eq!(style.size, theme.text.title_medium.size);
378        assert_eq!(style.color, Some(theme.scheme.on_primary_container));
379        assert_eq!(
380            inner.widgets.icon.color,
381            Some(theme.scheme.on_primary_container)
382        );
383        let gold = Color::rgb(1.0, 0.8, 0.2);
384        let inner =
385            Widget::<()>::theme_override(&avatar.foreground_color(gold), &theme).expect("a theme");
386        assert_eq!(inner.widgets.text.style.color, Some(gold));
387        assert!(Widget::<()>::theme_override(&CircleAvatar::<()>::new(), &theme).is_none());
388
389        let media = crate::MediaQuery::new(Size::new(400.0, 800.0)).with_text_scaler(2.0);
390        let avatar = CircleAvatar::<()>::initials("Ada");
391        let inside = Widget::<()>::media_override(&avatar, media).expect("unscaled");
392        assert_eq!(inside.text_scaler, 1.0);
393        assert!(Widget::<()>::media_override(&CircleAvatar::<()>::new(), media).is_none());
394    }
395
396    /// **A background picture under, a foreground one over**, both covering the circle.
397    #[test]
398    fn the_pictures_cover_the_circle() {
399        let theme = Theme::default();
400        let avatar = CircleAvatar::<()>::new().background_image(pixels());
401        let primitives = painted(&avatar, &theme);
402        assert!(
403            primitives
404                .iter()
405                .any(|p| matches!(p, Primitive::Layer { clip_shape, .. } if *clip_shape == frus_core::ClipShape::Oval)),
406            "the picture in the circle"
407        );
408        assert!(Widget::<()>::foreground(&avatar, &theme).is_none());
409        let front = CircleAvatar::<()>::new().foreground_image(pixels());
410        let decoration = Widget::<()>::foreground(&front, &theme).expect("over the child");
411        assert_eq!(decoration.shape, BoxShape::Circle);
412        assert_eq!(decoration.image.and_then(|i| i.fit), Some(BoxFit::Cover));
413        // A picture still on its way is the colour alone.
414        let waiting = CircleAvatar::<()>::new().foreground_image(None);
415        assert!(Widget::<()>::foreground(&waiting, &theme).is_none());
416    }
417
418    /// **The colour on its way is the one painted**: the runtime's, while it moves.
419    #[test]
420    fn the_colour_in_flight_is_painted() {
421        let theme = Theme::default();
422        let red = Color::rgb(1.0, 0.0, 0.0);
423        let half = Color::rgb(0.5, 0.0, 0.5);
424        let mut scene = Scene::new();
425        Widget::<()>::paint(
426            &CircleAvatar::<()>::new().background_color(red),
427            Rect::new(0.0, 0.0, 40.0, 40.0),
428            Status {
429                anim_color: Some(half),
430                ..Status::default()
431            },
432            &theme,
433            &mut scene,
434        );
435        let Primitive::Rect { color, .. } = &scene.primitives()[0] else {
436            panic!("the circle");
437        };
438        assert_eq!(*color, half);
439    }
440
441    /// **A selection that spans the texts goes on through the avatar**: the type is the
442    /// avatar's, the selection is not its to end.
443    #[test]
444    fn a_selection_area_reaches_the_initials() {
445        let mut theme = Theme::default();
446        theme.widgets.text.selectable = true;
447        let avatar = CircleAvatar::<()>::initials("Ada");
448        let inner = Widget::<()>::theme_override(&avatar, &theme).expect("a theme");
449        assert!(inner.widgets.text.selectable);
450    }
451
452    /// **A colour or a radius given is animated**; the theme's own are not asked for.
453    #[test]
454    fn a_given_colour_and_radius_are_animated() {
455        let red = Color::rgb(1.0, 0.0, 0.0);
456        let avatar = CircleAvatar::<()>::new().background_color(red).radius(10.0);
457        assert_eq!(Widget::<()>::anim_color(&avatar), Some(red));
458        assert_eq!(
459            Widget::<()>::anim_size(&avatar),
460            Some(Size::new(20.0, 20.0))
461        );
462        assert_eq!(
463            Widget::<()>::anim_duration(&avatar),
464            0.2,
465            "the reference's 200 ms"
466        );
467        let plain = CircleAvatar::<()>::new();
468        assert_eq!(Widget::<()>::anim_color(&plain), None);
469        assert_eq!(Widget::<()>::anim_size(&plain), None);
470        assert_eq!(Widget::<()>::anim_size(&plain.min_radius(5.0)), None);
471    }
472}