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, mix, 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 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
295pub 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
315pub 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
325pub 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#[derive(Debug, Clone)]
341pub struct AvatarGroup {
342 avatars: Vec<Avatar>,
343 size: Size,
344 max: Option<usize>,
345}
346
347pub 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 pub fn size(mut self, size: Size) -> Self {
360 self.size = size;
361 self
362 }
363
364 pub fn max_items(mut self, max: usize) -> Self {
368 self.max = Some(max);
369 self
370 }
371
372 pub fn visible(&self) -> (usize, usize) {
374 overflow(self.avatars.len(), self.max)
375 }
376}
377
378pub 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
392pub 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
433fn 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}