1use 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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77pub enum Shape {
78 #[default]
80 Circle,
81 Square,
83}
84
85impl Shape {
86 pub const ALL: [Shape; 2] = [Shape::Circle, Shape::Square];
87
88 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#[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
115pub 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#[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
142pub 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 pub fn image(mut self, handle: impl Into<image::Handle>) -> Self {
158 self.image = Some(handle.into());
159 self
160 }
161
162 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 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#[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
217fn 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 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
276pub const RING: f32 = 2.0;
281
282pub 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
302pub 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
312pub 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#[derive(Debug, Clone)]
328pub struct AvatarGroup {
329 avatars: Vec<Avatar>,
330 size: Size,
331 max: Option<usize>,
332}
333
334pub 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 pub fn size(mut self, size: Size) -> Self {
347 self.size = size;
348 self
349 }
350
351 pub fn max_items(mut self, max: usize) -> Self {
355 self.max = Some(max);
356 self
357 }
358
359 pub fn visible(&self) -> (usize, usize) {
361 overflow(self.avatars.len(), self.max)
362 }
363}
364
365pub 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
379pub 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
420fn 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}