1use 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
13pub const AVATAR_RADIUS: f32 = 20.0;
15
16const CHANGE: f32 = 0.2;
19
20pub 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 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 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 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
82 self.child = vec![Box::new(child)];
83 self
84 }
85
86 pub fn background_color(mut self, color: Color) -> Self {
88 self.background_color = Some(color);
89 self
90 }
91
92 pub fn foreground_color(mut self, color: Color) -> Self {
95 self.foreground_color = Some(color);
96 self
97 }
98
99 pub fn background_image(mut self, image: impl Into<Option<ImageHandle>>) -> Self {
102 self.background_image = image.into();
103 self
104 }
105
106 pub fn foreground_image(mut self, image: impl Into<Option<ImageHandle>>) -> Self {
108 self.foreground_image = image.into();
109 self
110 }
111
112 pub fn radius(mut self, radius: f32) -> Self {
114 self.radius = Some(radius);
115 self
116 }
117
118 pub fn min_radius(mut self, radius: f32) -> Self {
120 self.min_radius = Some(radius);
121 self
122 }
123
124 pub fn max_radius(mut self, radius: f32) -> Self {
126 self.max_radius = Some(radius);
127 self
128 }
129
130 pub fn size(self, diameter: f32) -> Self {
132 self.radius(diameter * 0.5)
133 }
134
135 fn unbounded(&self) -> bool {
137 self.radius.is_none() && self.min_radius.is_none() && self.max_radius.is_none()
138 }
139
140 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 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 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
172fn 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
188fn 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 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 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 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 fn anim_color(&self) -> Option<Color> {
268 self.background_color
269 }
270
271 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 #[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 #[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 #[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 #[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 let waiting = CircleAvatar::<()>::new().foreground_image(None);
415 assert!(Widget::<()>::foreground(&waiting, &theme).is_none());
416 }
417
418 #[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 #[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 #[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}