1use azul_core::{
18 dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec},
19 resources::{ImageRef, OptionImageRef},
20};
21use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
22use azul_css::{
23 props::{
24 basic::{color::ColorU, StyleFontSize},
25 layout::{LayoutDisplay, LayoutFlexDirection, LayoutJustifyContent, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutOverflow},
26 property::{CssProperty, *},
27 style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleTextColor},
28 },
29 AzString,
30};
31
32static AVATAR_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-avatar"))];
33static AVATAR_IMAGE_CLASS: &[IdOrClass] =
34 &[Class(AzString::from_const_str("__azul-native-avatar-image"))];
35static AVATAR_INITIALS_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
36 "__azul-native-avatar-initials",
37))];
38
39const AVATAR_BG_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
41const AVATAR_TEXT_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
43
44const AVATAR_BG_ITEMS: &[StyleBackgroundContent] =
45 &[StyleBackgroundContent::Color(AVATAR_BG_COLOR)];
46const AVATAR_BG: StyleBackgroundContentVec =
47 StyleBackgroundContentVec::from_const_slice(AVATAR_BG_ITEMS);
48
49#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
51#[repr(C)]
52pub enum AvatarSize {
53 Small,
55 #[default]
57 Medium,
58 Large,
60}
61
62impl AvatarSize {
63 #[allow(clippy::trivially_copy_pass_by_ref)] const fn diameter(&self) -> isize {
66 match self {
67 Self::Small => 24,
68 Self::Medium => 40,
69 Self::Large => 64,
70 }
71 }
72
73 #[allow(clippy::trivially_copy_pass_by_ref)] const fn radius(&self) -> isize {
76 self.diameter() / 2
77 }
78
79 #[allow(clippy::trivially_copy_pass_by_ref)] const fn font_size(&self) -> isize {
82 match self {
83 Self::Small => 11,
84 Self::Medium => 16,
85 Self::Large => 24,
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92#[repr(C)]
93pub struct Avatar {
94 pub image: OptionImageRef,
96 pub initials: AzString,
98 pub size: AvatarSize,
100 pub avatar_style: CssPropertyWithConditionsVec,
102}
103
104fn build_avatar_style(size: AvatarSize) -> CssPropertyWithConditionsVec {
108 let d = size.diameter();
109 let r = size.radius();
110 CssPropertyWithConditionsVec::from_vec(alloc::vec![
111 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
112 CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
113 LayoutFlexDirection::Row,
114 )),
115 CssPropertyWithConditions::simple(CssProperty::const_justify_content(
116 LayoutJustifyContent::Center,
117 )),
118 CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
119 CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
121 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
122 0,
123 ))),
124 CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(d))),
125 CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(d))),
126 CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
128 StyleBorderTopLeftRadius::const_px(r),
129 )),
130 CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
131 StyleBorderTopRightRadius::const_px(r),
132 )),
133 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
134 StyleBorderBottomLeftRadius::const_px(r),
135 )),
136 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
137 StyleBorderBottomRightRadius::const_px(r),
138 )),
139 CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
141 CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
142 CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(
143 size.font_size(),
144 ))),
145 CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
146 CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
147 inner: AVATAR_TEXT_COLOR,
148 })),
149 CssPropertyWithConditions::simple(CssProperty::const_background_content(AVATAR_BG)),
150 ])
151}
152
153fn build_image_style(size: AvatarSize) -> CssPropertyWithConditionsVec {
156 let d = size.diameter();
157 let r = size.radius();
158 CssPropertyWithConditionsVec::from_vec(alloc::vec![
159 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
160 0,
161 ))),
162 CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(d))),
163 CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(d))),
164 CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
165 StyleBorderTopLeftRadius::const_px(r),
166 )),
167 CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
168 StyleBorderTopRightRadius::const_px(r),
169 )),
170 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
171 StyleBorderBottomLeftRadius::const_px(r),
172 )),
173 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
174 StyleBorderBottomRightRadius::const_px(r),
175 )),
176 ])
177}
178
179impl Avatar {
180 #[inline]
182 #[must_use] pub fn create(initials: AzString) -> Self {
183 Self {
184 image: None.into(),
185 initials,
186 size: AvatarSize::Medium,
187 avatar_style: build_avatar_style(AvatarSize::Medium),
188 }
189 }
190
191 #[inline]
193 #[must_use] pub fn create_with_image(image: ImageRef) -> Self {
194 Self {
195 image: Some(image).into(),
196 initials: AzString::from_const_str(""),
197 size: AvatarSize::Medium,
198 avatar_style: build_avatar_style(AvatarSize::Medium),
199 }
200 }
201
202 #[inline]
204 pub fn set_image(&mut self, image: ImageRef) {
205 self.image = Some(image).into();
206 }
207
208 #[inline]
210 #[must_use] pub fn with_image(mut self, image: ImageRef) -> Self {
211 self.set_image(image);
212 self
213 }
214
215 #[inline]
217 pub fn set_size(&mut self, size: AvatarSize) {
218 self.size = size;
219 self.avatar_style = build_avatar_style(size);
220 }
221
222 #[inline]
224 #[must_use] pub fn with_size(mut self, size: AvatarSize) -> Self {
225 self.set_size(size);
226 self
227 }
228
229 #[inline]
231 #[must_use] pub fn swap_with_default(&mut self) -> Self {
232 let mut s = Self::create(AzString::from_const_str(""));
233 core::mem::swap(&mut s, self);
234 s
235 }
236
237 #[inline]
239 #[must_use] pub fn dom(self) -> Dom {
240 let size = self.size;
241 let child = match self.image.into_option() {
242 Some(image) => Dom::create_image(image)
243 .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_IMAGE_CLASS))
244 .with_css_props(build_image_style(size)),
245 None => Dom::create_text(self.initials)
246 .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_INITIALS_CLASS)),
247 };
248
249 Dom::create_div()
250 .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_CLASS))
251 .with_css_props(self.avatar_style)
252 .with_children(alloc::vec![child].into())
253 }
254}
255
256impl Default for Avatar {
257 fn default() -> Self {
258 Self::create(AzString::from_const_str(""))
259 }
260}
261
262impl From<Avatar> for Dom {
263 fn from(a: Avatar) -> Self {
264 a.dom()
265 }
266}
267
268#[cfg(test)]
269mod autotest_generated {
270 use std::collections::HashSet;
271
272 use azul_core::{dom::NodeType, resources::RawImageFormat};
273 use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
274
275 use super::*;
276
277 const ALL_SIZES: [AvatarSize; 3] = [AvatarSize::Small, AvatarSize::Medium, AvatarSize::Large];
284
285 fn test_image() -> ImageRef {
287 ImageRef::null_image(2, 2, RawImageFormat::RGBA8, Vec::new())
288 }
289
290 fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
292 v.as_ref().iter().map(|p| p.property.clone()).collect()
293 }
294
295 fn px(pv: &PixelValue) -> f32 {
299 assert_eq!(
300 pv.metric,
301 SizeMetric::Px,
302 "avatar geometry must be absolute px, got {:?}",
303 pv.metric
304 );
305 pv.number.get()
306 }
307
308 fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
309 v.as_ref().iter().find_map(|p| match &p.property {
310 CssProperty::Width(w) => match w.get_property()? {
311 LayoutWidth::Px(pv) => Some(px(pv)),
312 _ => None,
313 },
314 _ => None,
315 })
316 }
317
318 fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
319 v.as_ref().iter().find_map(|p| match &p.property {
320 CssProperty::Height(h) => match h.get_property()? {
321 LayoutHeight::Px(pv) => Some(px(pv)),
322 _ => None,
323 },
324 _ => None,
325 })
326 }
327
328 fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
329 v.as_ref().iter().find_map(|p| match &p.property {
330 CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
331 _ => None,
332 })
333 }
334
335 fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
338 v.as_ref()
339 .iter()
340 .filter_map(|p| match &p.property {
341 CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
342 CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
343 CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
344 CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
345 _ => None,
346 })
347 .collect()
348 }
349
350 fn all_pixel_values(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
352 v.as_ref()
353 .iter()
354 .filter_map(|p| match &p.property {
355 CssProperty::Width(w) => match w.get_property()? {
356 LayoutWidth::Px(pv) => Some(*pv),
357 _ => None,
358 },
359 CssProperty::Height(h) => match h.get_property()? {
360 LayoutHeight::Px(pv) => Some(*pv),
361 _ => None,
362 },
363 CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
364 CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| r.inner),
365 CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| r.inner),
366 CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| r.inner),
367 CssProperty::FontSize(f) => f.get_property().map(|f| f.inner),
368 _ => None,
369 })
370 .collect()
371 }
372
373 fn has_class(node: &Dom, name: &str) -> bool {
375 node.root
376 .get_ids_and_classes()
377 .as_ref()
378 .iter()
379 .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
380 }
381
382 fn inline_properties(node: &Dom) -> Vec<CssProperty> {
384 node.root
385 .style
386 .iter_inline_properties()
387 .map(|(p, _)| p.clone())
388 .collect()
389 }
390
391 fn only_child(dom: &Dom) -> &Dom {
394 let children = dom.children.as_ref();
395 assert_eq!(children.len(), 1, "an avatar renders exactly one child");
396 &children[0]
397 }
398
399 #[test]
404 fn avatar_size_getters_return_documented_values() {
405 assert_eq!(AvatarSize::Small.diameter(), 24);
406 assert_eq!(AvatarSize::Medium.diameter(), 40);
407 assert_eq!(AvatarSize::Large.diameter(), 64);
408
409 assert_eq!(AvatarSize::Small.radius(), 12);
410 assert_eq!(AvatarSize::Medium.radius(), 20);
411 assert_eq!(AvatarSize::Large.radius(), 32);
412
413 assert_eq!(AvatarSize::Small.font_size(), 11);
414 assert_eq!(AvatarSize::Medium.font_size(), 16);
415 assert_eq!(AvatarSize::Large.font_size(), 24);
416 }
417
418 #[test]
419 fn avatar_size_radius_is_exactly_half_the_diameter() {
420 for size in ALL_SIZES {
423 let d = size.diameter();
424 assert_eq!(
425 d % 2,
426 0,
427 "{size:?}: diameter {d} is odd, so radius() truncates and the avatar is not a circle"
428 );
429 assert_eq!(size.radius() * 2, d, "{size:?}: radius must be exactly d/2");
430 }
431 }
432
433 #[test]
434 fn avatar_size_getters_are_positive_and_font_fits_the_circle() {
435 for size in ALL_SIZES {
436 assert!(size.diameter() > 0, "{size:?}: non-positive diameter");
437 assert!(size.radius() > 0, "{size:?}: non-positive radius");
438 assert!(size.font_size() > 0, "{size:?}: non-positive font size");
439 assert!(
440 size.font_size() < size.diameter(),
441 "{size:?}: font size {} does not fit in a {}px circle",
442 size.font_size(),
443 size.diameter()
444 );
445 }
446 }
447
448 #[test]
449 fn avatar_size_getters_are_monotonic_in_the_size_variant() {
450 let d: Vec<isize> = ALL_SIZES.iter().map(AvatarSize::diameter).collect();
453 let f: Vec<isize> = ALL_SIZES.iter().map(AvatarSize::font_size).collect();
454 assert!(d[0] < d[1] && d[1] < d[2], "diameters not increasing: {d:?}");
455 assert!(f[0] < f[1] && f[1] < f[2], "font sizes not increasing: {f:?}");
456 }
457
458 #[test]
459 fn avatar_size_getters_are_pure_and_default_is_medium() {
460 assert_eq!(AvatarSize::default(), AvatarSize::Medium);
461 assert_eq!(AvatarSize::default().diameter(), 40);
462
463 for size in ALL_SIZES {
466 let copy = size;
467 assert_eq!(size.diameter(), copy.diameter());
468 assert_eq!(size.diameter(), size.diameter());
469 assert_eq!(size.radius(), size.radius());
470 assert_eq!(size.font_size(), size.font_size());
471 }
472 }
473
474 #[test]
479 fn build_avatar_style_box_matches_the_size_variant() {
480 for size in ALL_SIZES {
481 let style = build_avatar_style(size);
482 #[allow(clippy::cast_precision_loss)]
483 let d = size.diameter() as f32;
484 #[allow(clippy::cast_precision_loss)]
485 let r = size.radius() as f32;
486 #[allow(clippy::cast_precision_loss)]
487 let f = size.font_size() as f32;
488
489 assert_eq!(width_px(&style), Some(d), "{size:?}: width != diameter");
490 assert_eq!(height_px(&style), Some(d), "{size:?}: height != diameter");
491 assert_eq!(font_size_px(&style), Some(f), "{size:?}: wrong font size");
492 assert_eq!(
493 radii_px(&style),
494 vec![r, r, r, r],
495 "{size:?}: all four corners must carry the same radius"
496 );
497 }
498 }
499
500 #[test]
501 fn build_avatar_style_radius_is_half_the_box_so_it_renders_as_a_circle() {
502 for size in ALL_SIZES {
505 let style = build_avatar_style(size);
506 let d = width_px(&style).expect("width must be declared");
507 for r in radii_px(&style) {
508 assert!(
509 (r * 2.0 - d).abs() < f32::EPSILON,
510 "{size:?}: radius {r} is not half of the {d}px box"
511 );
512 }
513 }
514 }
515
516 #[test]
517 fn build_avatar_style_clips_and_centres_its_content() {
518 for size in ALL_SIZES {
519 let props = properties(&build_avatar_style(size));
520 let has = |p: &CssProperty| props.contains(p);
521
522 assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)));
523 assert!(has(&CssProperty::const_flex_direction(LayoutFlexDirection::Row)));
524 assert!(has(&CssProperty::const_justify_content(LayoutJustifyContent::Center)));
525 assert!(has(&CssProperty::const_align_items(LayoutAlignItems::Center)));
526 assert!(has(&CssProperty::align_self(LayoutAlignSelf::Start)));
527 assert!(has(&CssProperty::const_text_align(StyleTextAlign::Center)));
528 assert!(has(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
530 assert!(has(&CssProperty::const_overflow_y(LayoutOverflow::Hidden)));
531 assert!(has(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
533 }
534 }
535
536 #[test]
537 fn build_avatar_style_colors_are_the_documented_constants() {
538 let props = properties(&build_avatar_style(AvatarSize::Medium));
539
540 let text = props.iter().find_map(|p| match p {
541 CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
542 _ => None,
543 });
544 assert_eq!(text, Some(ColorU { r: 255, g: 255, b: 255, a: 255 }));
545
546 let bg = props.iter().find_map(|p| match p {
547 CssProperty::BackgroundContent(b) => b.get_property(),
548 _ => None,
549 });
550 let bg = bg.expect("a background must be declared behind the initials");
551 assert_eq!(bg.as_ref().len(), 1, "exactly one background layer");
552 assert_eq!(
553 bg.as_ref()[0],
554 StyleBackgroundContent::Color(ColorU { r: 108, g: 117, b: 125, a: 255 })
555 );
556 assert_eq!(text.expect("text colour").a, 255);
558 }
559
560 #[test]
561 fn build_avatar_style_declares_every_property_at_most_once() {
562 for size in ALL_SIZES {
565 let props = properties(&build_avatar_style(size));
566 let mut seen = HashSet::new();
567 for p in &props {
568 assert!(
569 seen.insert(core::mem::discriminant(p)),
570 "{size:?}: duplicate declaration of {p:?}"
571 );
572 }
573 assert_eq!(seen.len(), props.len());
574 }
575 }
576
577 #[test]
578 fn build_avatar_style_properties_are_all_unconditional() {
579 for size in ALL_SIZES {
582 for p in build_avatar_style(size).as_ref() {
583 assert!(
584 p.apply_if.as_ref().is_empty(),
585 "{size:?}: {:?} is conditional on a stateless widget",
586 p.property
587 );
588 }
589 }
590 }
591
592 #[test]
593 fn build_avatar_style_is_deterministic_and_size_dependent() {
594 for size in ALL_SIZES {
595 assert_eq!(
596 properties(&build_avatar_style(size)),
597 properties(&build_avatar_style(size)),
598 "{size:?}: two builds of the same size disagree"
599 );
600 }
601 assert_ne!(
603 properties(&build_avatar_style(AvatarSize::Small)),
604 properties(&build_avatar_style(AvatarSize::Large))
605 );
606 assert_ne!(
607 properties(&build_avatar_style(AvatarSize::Small)),
608 properties(&build_avatar_style(AvatarSize::Medium))
609 );
610 }
611
612 #[test]
613 fn build_image_style_fills_the_circle_exactly() {
614 for size in ALL_SIZES {
615 let container = build_avatar_style(size);
616 let image = build_image_style(size);
617
618 assert_eq!(width_px(&image), width_px(&container), "{size:?}: image width");
621 assert_eq!(height_px(&image), height_px(&container), "{size:?}: image height");
622 assert_eq!(radii_px(&image), radii_px(&container), "{size:?}: image radii");
623 assert_eq!(radii_px(&image).len(), 4, "{size:?}: all four corners rounded");
624 assert!(properties(&image)
626 .contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
627 }
628 }
629
630 #[test]
631 fn build_image_style_declares_every_property_once_and_unconditionally() {
632 for size in ALL_SIZES {
633 let style = build_image_style(size);
634 let mut seen = HashSet::new();
635 for p in style.as_ref() {
636 assert!(
637 seen.insert(core::mem::discriminant(&p.property)),
638 "{size:?}: duplicate declaration of {:?}",
639 p.property
640 );
641 assert!(p.apply_if.as_ref().is_empty(), "{size:?}: conditional image property");
642 }
643 assert_eq!(
644 properties(&build_image_style(size)),
645 properties(&build_image_style(size)),
646 "{size:?}: build_image_style is not deterministic"
647 );
648 }
649 }
650
651 #[test]
652 fn every_emitted_length_is_a_finite_non_negative_px_value() {
653 for size in ALL_SIZES {
656 for style in [build_avatar_style(size), build_image_style(size)] {
657 let values = all_pixel_values(&style);
658 assert!(!values.is_empty(), "{size:?}: no lengths emitted at all");
659 for pv in values {
660 let n = px(&pv); assert!(n.is_finite(), "{size:?}: non-finite length {n}");
662 assert!(n >= 0.0, "{size:?}: negative length {n}");
663 assert!(n <= 4096.0, "{size:?}: implausibly large length {n}");
664 }
665 }
666 }
667 }
668
669 #[test]
674 fn create_round_trips_initials_verbatim() {
675 let long = "x".repeat(100_000);
679 let cases = [
680 "",
681 "AB",
682 "e\u{0301}", "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", "\u{5E9}\u{5DC}", "\0\0", " ", long.as_str(),
688 ];
689
690 for s in cases {
691 let a = Avatar::create(AzString::from(s.to_string()));
692 assert_eq!(a.initials.as_str(), s, "initials were not preserved verbatim");
693 assert_eq!(a.initials.len(), s.len(), "byte length changed (NUL truncation?)");
694 assert!(a.image.is_none(), "create() must not set an image");
695 assert_eq!(a.size, AvatarSize::Medium);
696 assert_eq!(properties(&a.avatar_style), properties(&build_avatar_style(AvatarSize::Medium)));
697 }
698 }
699
700 #[test]
701 fn create_with_image_keeps_the_image_and_empty_initials() {
702 let img = test_image();
703 let hash = img.get_hash();
704 let a = Avatar::create_with_image(img);
705
706 assert!(a.image.is_some());
707 match &a.image {
708 OptionImageRef::Some(i) => assert_eq!(i.get_hash(), hash, "a different image came back"),
709 OptionImageRef::None => panic!("image was dropped by create_with_image"),
710 }
711 assert_eq!(a.initials.as_str(), "", "image avatars have empty fallback initials");
712 assert_eq!(a.size, AvatarSize::Medium);
713 }
714
715 #[test]
716 fn default_avatar_equals_an_empty_medium_avatar() {
717 let d = Avatar::default();
718 assert_eq!(d, Avatar::create(AzString::from_const_str("")));
719 assert_eq!(d.clone(), d, "Clone must preserve equality");
720 assert_ne!(d, Avatar::create(AzString::from_const_str("AB")));
721 assert_ne!(
722 Avatar::create(AzString::from_const_str("AB")),
723 Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Large),
724 "avatars of different sizes must not compare equal"
725 );
726 }
727
728 #[test]
733 fn with_image_and_set_image_agree_and_keep_the_other_fields() {
734 let base = Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Large);
735
736 let mut mutated = base.clone();
737 mutated.set_image(test_image());
738 let built = base.clone().with_image(test_image());
739
740 assert!(mutated.image.is_some() && built.image.is_some());
741 for a in [&mutated, &built] {
743 assert_eq!(a.size, AvatarSize::Large);
744 assert_eq!(a.initials.as_str(), "AB", "set_image must keep the fallback initials");
745 assert_eq!(properties(&a.avatar_style), properties(&base.avatar_style));
746 }
747 }
748
749 #[test]
750 fn set_image_replaces_a_previous_image_rather_than_stacking() {
751 let first = test_image();
752 let second = test_image();
753 let (h1, h2) = (first.get_hash(), second.get_hash());
754 assert_ne!(h1, h2, "fixture bug: the two images must be distinguishable");
755
756 let mut a = Avatar::create_with_image(first);
757 a.set_image(second);
758 match &a.image {
759 OptionImageRef::Some(i) => assert_eq!(i.get_hash(), h2, "the second image must win"),
760 OptionImageRef::None => panic!("image lost"),
761 }
762 }
763
764 #[test]
765 fn set_size_recomputes_the_style_without_growing_it() {
766 let mut a = Avatar::create(AzString::from_const_str("AB"));
769 let expected_len = build_avatar_style(AvatarSize::Medium).as_ref().len();
770
771 for round in 0..50 {
772 let size = ALL_SIZES[round % ALL_SIZES.len()];
773 a.set_size(size);
774
775 assert_eq!(a.size, size, "round {round}: size field not updated");
776 assert_eq!(
777 a.avatar_style.as_ref().len(),
778 expected_len,
779 "round {round}: style vec changed length — stale declarations?"
780 );
781 assert_eq!(
782 properties(&a.avatar_style),
783 properties(&build_avatar_style(size)),
784 "round {round}: style does not match the freshly built one"
785 );
786 assert_eq!(a.initials.as_str(), "AB", "round {round}: set_size ate the initials");
787 }
788 }
789
790 #[test]
791 fn set_size_keeps_the_image() {
792 let mut a = Avatar::create_with_image(test_image());
793 a.set_size(AvatarSize::Small);
794 assert!(a.image.is_some(), "set_size must not drop the image");
795 assert_eq!(width_px(&a.avatar_style), Some(24.0));
796 }
797
798 #[test]
799 fn with_size_is_last_call_wins_and_matches_set_size() {
800 let chained = Avatar::create(AzString::from_const_str("AB"))
801 .with_size(AvatarSize::Large)
802 .with_size(AvatarSize::Small)
803 .with_size(AvatarSize::Medium);
804
805 let mut mutated = Avatar::create(AzString::from_const_str("AB"));
806 mutated.set_size(AvatarSize::Large);
807 mutated.set_size(AvatarSize::Small);
808 mutated.set_size(AvatarSize::Medium);
809
810 assert_eq!(chained, mutated, "builder and mutator must agree");
811 assert_eq!(chained.size, AvatarSize::Medium);
812 assert_eq!(
813 properties(&chained.avatar_style),
814 properties(&build_avatar_style(AvatarSize::Medium))
815 );
816 }
817
818 #[test]
823 fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
824 let mut a = Avatar::create(AzString::from_const_str("AB"))
825 .with_size(AvatarSize::Large)
826 .with_image(test_image());
827
828 let taken = a.swap_with_default();
829
830 assert_eq!(taken.initials.as_str(), "AB");
832 assert_eq!(taken.size, AvatarSize::Large);
833 assert!(taken.image.is_some());
834 assert_eq!(properties(&taken.avatar_style), properties(&build_avatar_style(AvatarSize::Large)));
835
836 assert_eq!(a, Avatar::default());
839 assert!(a.image.is_none(), "the image must not survive in the emptied avatar");
840 assert_eq!(a.initials.as_str(), "");
841 assert_eq!(a.size, AvatarSize::Medium);
842 assert_eq!(properties(&a.avatar_style), properties(&build_avatar_style(AvatarSize::Medium)));
843 }
844
845 #[test]
846 fn swap_with_default_is_idempotent_on_an_already_default_avatar() {
847 let mut a = Avatar::default();
848 let first = a.swap_with_default();
849 let second = a.swap_with_default();
850 assert_eq!(first, Avatar::default());
851 assert_eq!(second, Avatar::default());
852 assert_eq!(a, Avatar::default());
853 }
854
855 #[test]
860 fn dom_of_an_initials_avatar_is_a_circle_wrapping_the_text() {
861 for size in ALL_SIZES {
862 let avatar = Avatar::create(AzString::from_const_str("AB")).with_size(size);
863 let expected = properties(&avatar.avatar_style);
864 let dom = avatar.dom();
865
866 assert!(has_class(&dom, "__azul-native-avatar"), "{size:?}: missing root class");
867 assert_eq!(
868 inline_properties(&dom),
869 expected,
870 "{size:?}: the container lost its computed style"
871 );
872
873 let child = only_child(&dom);
874 assert!(has_class(child, "__azul-native-avatar-initials"));
875 match child.root.get_node_type() {
876 NodeType::Text(s) => assert_eq!(s.as_ref().as_str(), "AB"),
877 other => panic!("{size:?}: expected a text child, got {other:?}"),
878 }
879 }
880 }
881
882 #[test]
883 fn dom_of_an_image_avatar_renders_the_image_and_drops_the_initials() {
884 for size in ALL_SIZES {
885 let img = test_image();
886 let hash = img.get_hash();
887 let avatar = Avatar::create(AzString::from_const_str("AB"))
890 .with_size(size)
891 .with_image(img);
892 let dom = avatar.dom();
893
894 let child = only_child(&dom);
895 assert!(has_class(child, "__azul-native-avatar-image"), "{size:?}: missing image class");
896 assert!(
897 !has_class(child, "__azul-native-avatar-initials"),
898 "{size:?}: initials rendered on top of the image"
899 );
900 match child.root.get_node_type() {
901 NodeType::Image(i) => assert_eq!(i.as_ref().get_hash(), hash, "{size:?}: wrong image"),
902 other => panic!("{size:?}: expected an image child, got {other:?}"),
903 }
904 assert_eq!(
905 inline_properties(child),
906 properties(&build_image_style(size)),
907 "{size:?}: the image child does not carry the matching circular style"
908 );
909 }
910 }
911
912 #[test]
913 fn dom_preserves_adversarial_initials_verbatim() {
914 let long = "\u{1F600}".repeat(10_000); for s in ["", "\0", "e\u{0301}", "\u{5E9}\u{5DC}", long.as_str()] {
916 let dom = Avatar::create(AzString::from(s.to_string())).dom();
917 let child = only_child(&dom);
918 match child.root.get_node_type() {
919 NodeType::Text(t) => {
920 assert_eq!(t.as_ref().as_str(), s, "text node mangled the initials");
921 assert_eq!(t.as_ref().len(), s.len(), "text node changed the byte length");
922 }
923 other => panic!("expected a text child, got {other:?}"),
924 }
925 }
926 }
927
928 #[test]
929 fn dom_and_the_from_impl_agree() {
930 let avatar = Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Small);
931 assert_eq!(Dom::from(avatar.clone()), avatar.dom());
932 }
933
934 #[test]
935 fn dom_geometry_is_consistent_between_container_and_image_after_set_size() {
936 for size in ALL_SIZES {
939 let dom = Avatar::create_with_image(test_image()).with_size(size).dom();
940 #[allow(clippy::cast_precision_loss)]
941 let d = size.diameter() as f32;
942
943 let container: Vec<CssProperty> = inline_properties(&dom);
944 let child: Vec<CssProperty> = inline_properties(only_child(&dom));
945 let width_of = |props: &[CssProperty]| {
946 props.iter().find_map(|p| match p {
947 CssProperty::Width(w) => match w.get_property()? {
948 LayoutWidth::Px(pv) => Some(px(pv)),
949 _ => None,
950 },
951 _ => None,
952 })
953 };
954 assert_eq!(width_of(&container), Some(d), "{size:?}: container width");
955 assert_eq!(width_of(&child), Some(d), "{size:?}: image width");
956 }
957 }
958
959 #[test]
960 fn assigning_the_size_field_directly_desyncs_the_container_from_the_image() {
961 let mut a = Avatar::create_with_image(test_image());
967 a.size = AvatarSize::Large; let dom = a.dom();
969
970 assert_eq!(
971 width_px(&build_avatar_style(AvatarSize::Medium)),
972 Some(40.0),
973 "fixture assumption: the stored style is still Medium's"
974 );
975 let container = inline_properties(&dom);
976 let container_width = container.iter().find_map(|p| match p {
977 CssProperty::Width(w) => match w.get_property()? {
978 LayoutWidth::Px(pv) => Some(px(pv)),
979 _ => None,
980 },
981 _ => None,
982 });
983 assert_eq!(container_width, Some(40.0), "container still uses the stored Medium style");
984 assert_eq!(
985 inline_properties(only_child(&dom)),
986 properties(&build_image_style(AvatarSize::Large)),
987 "the image, however, follows the freshly assigned `size` field"
988 );
989 }
990}