Skip to main content

azul_layout/widgets/
avatar.rs

1//! Avatar widget — a circular container showing either an image or short
2//! initials text, in one of three size variants. A stateless widget (no
3//! callbacks), a styled near-clone of [`crate::widgets::label::Label`] /
4//! [`crate::widgets::button::Button`] (image-or-text content) rendered as a
5//! `border-radius: 50%` circle.
6//!
7//! If an [`ImageRef`] is set it is rendered (clipped to the circle); otherwise
8//! the `initials` string is shown centred on a neutral background.
9//!
10//! TODO2: the circular image relies on `overflow: hidden` + `border-radius` on
11//! the container clipping the child image; whether the renderer clips a child
12//! image to the parent's rounded corners is not GUI-verified here, so the image
13//! is *also* given its own matching `border-radius` as a fallback.
14//!
15//! Key types: [`Avatar`], [`AvatarSize`].
16
17use 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
39/// Neutral background (#6c757d, grey) shown behind the initials.
40const AVATAR_BG_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
41/// Initials text colour (white).
42const 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/// Diameter (and font) size variant of an [`Avatar`].
50#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
51#[repr(C)]
52pub enum AvatarSize {
53    /// 24px diameter.
54    Small,
55    /// 40px diameter — the default.
56    #[default]
57    Medium,
58    /// 64px diameter.
59    Large,
60}
61
62impl AvatarSize {
63    /// Diameter of the circle in logical pixels.
64    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
65    const fn diameter(&self) -> isize {
66        match self {
67            Self::Small => 24,
68            Self::Medium => 40,
69            Self::Large => 64,
70        }
71    }
72
73    /// Corner radius for a full circle = diameter / 2.
74    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
75    const fn radius(&self) -> isize {
76        self.diameter() / 2
77    }
78
79    /// Initials font size in logical pixels.
80    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
81    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/// A circular avatar showing an image or initials. Stateless.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[repr(C)]
93pub struct Avatar {
94    /// Optional image; when present it is shown instead of the initials.
95    pub image: OptionImageRef,
96    /// Fallback initials shown when no image is set.
97    pub initials: AzString,
98    /// The size variant.
99    pub size: AvatarSize,
100    /// The computed inline style for the circular container.
101    pub avatar_style: CssPropertyWithConditionsVec,
102}
103
104/// Builds the circular container style for a given size. Diameter, corner radius
105/// and font size are size-dependent, so the style is built at runtime per the
106/// recipe's "runtime vec when param-dependent" path (see `badge::build_badge_style`).
107fn 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        // Hug content rather than stretch across a flex parent's cross axis.
120        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        // circle
127        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        // clip the image (or overflowing initials) to the circle
140        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
153/// Builds the inner image style: fills the circle and is itself rounded so the
154/// image reads as a circle even if `overflow: hidden` clipping is unavailable.
155fn 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    /// Creates a medium initials avatar with the given text.
181    #[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    /// Creates a medium image avatar (with empty fallback initials).
192    #[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    /// Sets the avatar image (shown instead of the initials).
203    #[inline]
204    pub fn set_image(&mut self, image: ImageRef) {
205        self.image = Some(image).into();
206    }
207
208    /// Builder-style setter for the avatar image.
209    #[inline]
210    #[must_use] pub fn with_image(mut self, image: ImageRef) -> Self {
211        self.set_image(image);
212        self
213    }
214
215    /// Sets the size variant, recomputing the style.
216    #[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    /// Builder-style setter for the size variant.
223    #[inline]
224    #[must_use] pub fn with_size(mut self, size: AvatarSize) -> Self {
225        self.set_size(size);
226        self
227    }
228
229    /// Replaces `self` with a default (empty medium) avatar and returns the original.
230    #[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    /// Converts this avatar into a DOM subtree with the `__azul-native-avatar` class.
238    #[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    // ------------------------------------------------------------------
278    // Helpers
279    // ------------------------------------------------------------------
280
281    /// Every variant of `AvatarSize` — the full input domain of the getters and
282    /// of `build_avatar_style` / `build_image_style`.
283    const ALL_SIZES: [AvatarSize; 3] = [AvatarSize::Small, AvatarSize::Medium, AvatarSize::Large];
284
285    /// A 2x2 placeholder image: `null_image` needs neither a decoder nor a GPU.
286    fn test_image() -> ImageRef {
287        ImageRef::null_image(2, 2, RawImageFormat::RGBA8, Vec::new())
288    }
289
290    /// The declared properties of a style vec, in declaration order.
291    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
292        v.as_ref().iter().map(|p| p.property.clone()).collect()
293    }
294
295    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length —
296    /// an `em`/`%` slipping in here would make the "circle" resolve against the
297    /// parent instead of the intended diameter.
298    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    /// The four corner radii in declaration order (top-left, top-right,
336    /// bottom-left, bottom-right).
337    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    /// Every `PixelValue` a style vec mentions (sizes, radii, font size).
351    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    /// True if `node` carries the CSS class `name`.
374    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    /// The properties of a rendered node's *inline* style, in declaration order.
383    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    /// The single child of a rendered avatar DOM (the widget is always
392    /// `container -> [image | text]`).
393    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    // ------------------------------------------------------------------
400    // AvatarSize::diameter / radius / font_size  (getters)
401    // ------------------------------------------------------------------
402
403    #[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        // `radius()` is an integer division: an odd diameter would truncate and
421        // the "circle" would render as a rounded square. Every variant must be even.
422        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        // Small < Medium < Large must hold for both the box and the text, or a
451        // "larger" avatar could render smaller than a "smaller" one.
452        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        // The getters take `&self` on a `Copy` enum: repeated calls (and calls
464        // through a copy) must be side-effect free and identical.
465        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    // ------------------------------------------------------------------
475    // build_avatar_style / build_image_style  (numeric)
476    // ------------------------------------------------------------------
477
478    #[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        // The widget's whole premise: r == d/2 in the *emitted* style, not just
503        // in the getters.
504        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            // Both axes must clip, or an image/long initials escape the circle.
529            assert!(has(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
530            assert!(has(&CssProperty::const_overflow_y(LayoutOverflow::Hidden)));
531            // flex-grow: 0 — the avatar hugs its fixed diameter in a flex parent.
532            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        // Both colours must be fully opaque, or the initials wash out.
557        assert_eq!(text.expect("text colour").a, 255);
558    }
559
560    #[test]
561    fn build_avatar_style_declares_every_property_at_most_once() {
562        // A duplicated declaration is a last-one-wins ambiguity: two `width`s
563        // would silently make one of them dead.
564        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        // Every declaration must apply with no `:hover`/state condition — a
580        // conditional one would simply never paint on a stateless widget.
581        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        // Different variants must not collapse onto the same style.
602        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            // The image must be exactly as big and as round as its container,
619            // otherwise it either leaves a gap or is clipped square at a corner.
620            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            // The image must not grow past the circle in a flex row.
625            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        // `isize` -> `PixelValue` is the only numeric conversion in this file:
654        // guard against a NaN/inf/negative length ever reaching the solver.
655        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); // also asserts SizeMetric::Px
661                    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    // ------------------------------------------------------------------
670    // Avatar::create / create_with_image  (constructors)
671    // ------------------------------------------------------------------
672
673    #[test]
674    fn create_round_trips_initials_verbatim() {
675        // Adversarial strings: empty, combining marks, ZWJ emoji, RTL, embedded
676        // NULs (AzString is length-based, so a NUL must NOT truncate), and a
677        // string far longer than any real set of initials.
678        let long = "x".repeat(100_000);
679        let cases = [
680            "",
681            "AB",
682            "e\u{0301}",                  // e + combining acute
683            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family
684            "\u{5E9}\u{5DC}",             // RTL Hebrew
685            "\0\0",                       // embedded NULs
686            "  ",                         // whitespace only
687            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    // ------------------------------------------------------------------
729    // set_image / with_image / set_size / with_size  (setters)
730    // ------------------------------------------------------------------
731
732    #[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        // Setting an image must not disturb the size, the style, or the fallback text.
742        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        // A `push`-instead-of-replace bug would make the style vec grow on every
767        // call and leave stale (earlier-size) declarations behind.
768        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    // ------------------------------------------------------------------
819    // swap_with_default
820    // ------------------------------------------------------------------
821
822    #[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        // The returned value is the *original*, intact.
831        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        // What is left behind is a *default* avatar — in particular its style
837        // must be Medium's, not a stale Large one.
838        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    // ------------------------------------------------------------------
856    // Avatar::dom
857    // ------------------------------------------------------------------
858
859    #[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            // The initials are only a *fallback*: with an image set they must not
888            // be rendered as a second child on top of the image.
889            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); // 40 000 bytes of emoji
915        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        // Through the supported API (`set_size` / `with_size`) the container and
937        // the image must always resolve to the same diameter.
938        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        // `size` and `avatar_style` are both public, and `dom()` reads the image
962        // geometry from `size` while the container keeps the *stored* style. So a
963        // direct field write (bypassing `set_size`) silently produces an avatar
964        // whose image is a different diameter than its circle. Pinned here as the
965        // current behaviour — `set_size` is the only correct path.
966        let mut a = Avatar::create_with_image(test_image());
967        a.size = AvatarSize::Large; // NOT set_size: the style is not recomputed
968        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}