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

            
17
use azul_core::{
18
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec},
19
    resources::{ImageRef, OptionImageRef},
20
};
21
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
22
use 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

            
32
static AVATAR_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-avatar"))];
33
static AVATAR_IMAGE_CLASS: &[IdOrClass] =
34
    &[Class(AzString::from_const_str("__azul-native-avatar-image"))];
35
static 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.
40
const AVATAR_BG_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
41
/// Initials text colour (white).
42
const AVATAR_TEXT_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
43

            
44
const AVATAR_BG_ITEMS: &[StyleBackgroundContent] =
45
    &[StyleBackgroundContent::Color(AVATAR_BG_COLOR)];
46
const 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)]
52
pub enum AvatarSize {
53
    /// 24px diameter.
54
    Small,
55
    /// 40px diameter — the default.
56
    #[default]
57
    Medium,
58
    /// 64px diameter.
59
    Large,
60
}
61

            
62
impl 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
528
    const fn diameter(&self) -> isize {
66
528
        match self {
67
137
            Self::Small => 24,
68
252
            Self::Medium => 40,
69
139
            Self::Large => 64,
70
        }
71
528
    }
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
256
    const fn radius(&self) -> isize {
76
256
        self.diameter() / 2
77
256
    }
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
233
    const fn font_size(&self) -> isize {
82
233
        match self {
83
59
            Self::Small => 11,
84
116
            Self::Medium => 16,
85
58
            Self::Large => 24,
86
        }
87
233
    }
88
}
89

            
90
/// A circular avatar showing an image or initials. Stateless.
91
#[derive(Debug, Clone, PartialEq, Eq)]
92
#[repr(C)]
93
pub 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`).
107
212
fn build_avatar_style(size: AvatarSize) -> CssPropertyWithConditionsVec {
108
212
    let d = size.diameter();
109
212
    let r = size.radius();
110
212
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
111
212
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
112
212
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
113
212
            LayoutFlexDirection::Row,
114
        )),
115
212
        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
116
212
            LayoutJustifyContent::Center,
117
        )),
118
212
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
119
        // Hug content rather than stretch across a flex parent's cross axis.
120
212
        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
121
212
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
122
            0,
123
        ))),
124
212
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(d))),
125
212
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(d))),
126
        // circle
127
212
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
128
212
            StyleBorderTopLeftRadius::const_px(r),
129
        )),
130
212
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
131
212
            StyleBorderTopRightRadius::const_px(r),
132
        )),
133
212
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
134
212
            StyleBorderBottomLeftRadius::const_px(r),
135
        )),
136
212
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
137
212
            StyleBorderBottomRightRadius::const_px(r),
138
        )),
139
        // clip the image (or overflowing initials) to the circle
140
212
        CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
141
212
        CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
142
212
        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(
143
212
            size.font_size(),
144
        ))),
145
212
        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
146
212
        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
147
212
            inner: AVATAR_TEXT_COLOR,
148
212
        })),
149
212
        CssPropertyWithConditions::simple(CssProperty::const_background_content(AVATAR_BG)),
150
    ])
151
212
}
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.
155
26
fn build_image_style(size: AvatarSize) -> CssPropertyWithConditionsVec {
156
26
    let d = size.diameter();
157
26
    let r = size.radius();
158
26
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
159
26
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
160
            0,
161
        ))),
162
26
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(d))),
163
26
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(d))),
164
26
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
165
26
            StyleBorderTopLeftRadius::const_px(r),
166
        )),
167
26
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
168
26
            StyleBorderTopRightRadius::const_px(r),
169
        )),
170
26
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
171
26
            StyleBorderBottomLeftRadius::const_px(r),
172
        )),
173
26
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
174
26
            StyleBorderBottomRightRadius::const_px(r),
175
        )),
176
    ])
177
26
}
178

            
179
impl Avatar {
180
    /// Creates a medium initials avatar with the given text.
181
    #[inline]
182
40
    #[must_use] pub fn create(initials: AzString) -> Self {
183
40
        Self {
184
40
            image: None.into(),
185
40
            initials,
186
40
            size: AvatarSize::Medium,
187
40
            avatar_style: build_avatar_style(AvatarSize::Medium),
188
40
        }
189
40
    }
190

            
191
    /// Creates a medium image avatar (with empty fallback initials).
192
    #[inline]
193
7
    #[must_use] pub fn create_with_image(image: ImageRef) -> Self {
194
7
        Self {
195
7
            image: Some(image).into(),
196
7
            initials: AzString::from_const_str(""),
197
7
            size: AvatarSize::Medium,
198
7
            avatar_style: build_avatar_style(AvatarSize::Medium),
199
7
        }
200
7
    }
201

            
202
    /// Sets the avatar image (shown instead of the initials).
203
    #[inline]
204
7
    pub fn set_image(&mut self, image: ImageRef) {
205
7
        self.image = Some(image).into();
206
7
    }
207

            
208
    /// Builder-style setter for the avatar image.
209
    #[inline]
210
5
    #[must_use] pub fn with_image(mut self, image: ImageRef) -> Self {
211
5
        self.set_image(image);
212
5
        self
213
5
    }
214

            
215
    /// Sets the size variant, recomputing the style.
216
    #[inline]
217
70
    pub fn set_size(&mut self, size: AvatarSize) {
218
70
        self.size = size;
219
70
        self.avatar_style = build_avatar_style(size);
220
70
    }
221

            
222
    /// Builder-style setter for the size variant.
223
    #[inline]
224
16
    #[must_use] pub fn with_size(mut self, size: AvatarSize) -> Self {
225
16
        self.set_size(size);
226
16
        self
227
16
    }
228

            
229
    /// Replaces `self` with a default (empty medium) avatar and returns the original.
230
    #[inline]
231
3
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
232
3
        let mut s = Self::create(AzString::from_const_str(""));
233
3
        core::mem::swap(&mut s, self);
234
3
        s
235
3
    }
236

            
237
    /// Converts this avatar into a DOM subtree with the `__azul-native-avatar` class.
238
    #[inline]
239
19
    #[must_use] pub fn dom(self) -> Dom {
240
19
        let size = self.size;
241
19
        let child = match self.image.into_option() {
242
7
            Some(image) => Dom::create_image(image)
243
7
                .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_IMAGE_CLASS))
244
7
                .with_css_props(build_image_style(size)),
245
            // The initials are a `<p>` for the same reason the image branch is a
246
            // replaced node: a raw text child would be a rect-less anonymous box,
247
            // so the initials class could never be styled by the author.
248
12
            None => Dom::create_p_with_text(self.initials)
249
12
                .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_INITIALS_CLASS)),
250
        };
251

            
252
19
        Dom::create_div()
253
19
            .with_ids_and_classes(IdOrClassVec::from_const_slice(AVATAR_CLASS))
254
19
            .with_css_props(self.avatar_style)
255
19
            .with_children(alloc::vec![child].into())
256
19
    }
257
}
258

            
259
impl Default for Avatar {
260
6
    fn default() -> Self {
261
6
        Self::create(AzString::from_const_str(""))
262
6
    }
263
}
264

            
265
impl From<Avatar> for Dom {
266
1
    fn from(a: Avatar) -> Self {
267
1
        a.dom()
268
1
    }
269
}
270

            
271
#[cfg(test)]
272
mod autotest_generated {
273
    use std::collections::HashSet;
274

            
275
    use azul_core::{dom::NodeType, resources::RawImageFormat};
276
    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
277

            
278
    use super::*;
279

            
280
    // ------------------------------------------------------------------
281
    // Helpers
282
    // ------------------------------------------------------------------
283

            
284
    /// Every variant of `AvatarSize` — the full input domain of the getters and
285
    /// of `build_avatar_style` / `build_image_style`.
286
    const ALL_SIZES: [AvatarSize; 3] = [AvatarSize::Small, AvatarSize::Medium, AvatarSize::Large];
287

            
288
    /// A 2x2 placeholder image: `null_image` needs neither a decoder nor a GPU.
289
    fn test_image() -> ImageRef {
290
        ImageRef::null_image(2, 2, RawImageFormat::RGBA8, Vec::new())
291
    }
292

            
293
    /// The declared properties of a style vec, in declaration order.
294
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
295
        v.as_ref().iter().map(|p| p.property.clone()).collect()
296
    }
297

            
298
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length —
299
    /// an `em`/`%` slipping in here would make the "circle" resolve against the
300
    /// parent instead of the intended diameter.
301
    fn px(pv: &PixelValue) -> f32 {
302
        assert_eq!(
303
            pv.metric,
304
            SizeMetric::Px,
305
            "avatar geometry must be absolute px, got {:?}",
306
            pv.metric
307
        );
308
        pv.number.get()
309
    }
310

            
311
    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
312
        v.as_ref().iter().find_map(|p| match &p.property {
313
            CssProperty::Width(w) => match w.get_property()? {
314
                LayoutWidth::Px(pv) => Some(px(pv)),
315
                _ => None,
316
            },
317
            _ => None,
318
        })
319
    }
320

            
321
    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
322
        v.as_ref().iter().find_map(|p| match &p.property {
323
            CssProperty::Height(h) => match h.get_property()? {
324
                LayoutHeight::Px(pv) => Some(px(pv)),
325
                _ => None,
326
            },
327
            _ => None,
328
        })
329
    }
330

            
331
    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
332
        v.as_ref().iter().find_map(|p| match &p.property {
333
            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
334
            _ => None,
335
        })
336
    }
337

            
338
    /// The four corner radii in declaration order (top-left, top-right,
339
    /// bottom-left, bottom-right).
340
    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
341
        v.as_ref()
342
            .iter()
343
            .filter_map(|p| match &p.property {
344
                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
345
                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
346
                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
347
                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
348
                _ => None,
349
            })
350
            .collect()
351
    }
352

            
353
    /// Every `PixelValue` a style vec mentions (sizes, radii, font size).
354
    fn all_pixel_values(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
355
        v.as_ref()
356
            .iter()
357
            .filter_map(|p| match &p.property {
358
                CssProperty::Width(w) => match w.get_property()? {
359
                    LayoutWidth::Px(pv) => Some(*pv),
360
                    _ => None,
361
                },
362
                CssProperty::Height(h) => match h.get_property()? {
363
                    LayoutHeight::Px(pv) => Some(*pv),
364
                    _ => None,
365
                },
366
                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
367
                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| r.inner),
368
                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| r.inner),
369
                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| r.inner),
370
                CssProperty::FontSize(f) => f.get_property().map(|f| f.inner),
371
                _ => None,
372
            })
373
            .collect()
374
    }
375

            
376
    /// True if `node` carries the CSS class `name`.
377
    fn has_class(node: &Dom, name: &str) -> bool {
378
        node.root
379
            .get_ids_and_classes()
380
            .as_ref()
381
            .iter()
382
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
383
    }
384

            
385
    /// The properties of a rendered node's *inline* style, in declaration order.
386
    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
387
        node.root
388
            .style
389
            .iter_inline_properties()
390
            .map(|(p, _)| p.clone())
391
            .collect()
392
    }
393

            
394
    /// The single child of a rendered avatar DOM (the widget is always
395
    /// `container -> [image | p > text]`).
396
    fn only_child(dom: &Dom) -> &Dom {
397
        let children = dom.children.as_ref();
398
        assert_eq!(children.len(), 1, "an avatar renders exactly one child");
399
        &children[0]
400
    }
401

            
402
    /// The text carried by a text node, looking through the `<p>` block
403
    /// wrapper the label convention mandates (`p > text`).
404
    fn text_of(node: &Dom) -> Option<&str> {
405
        match node.root.get_node_type() {
406
            NodeType::Text(s) => Some(s.as_ref().as_str()),
407
            NodeType::P => match node.children.as_ref() {
408
                [only] => match only.root.get_node_type() {
409
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
410
                    _ => None,
411
                },
412
                _ => None,
413
            },
414
            _ => None,
415
        }
416
    }
417

            
418
    // ------------------------------------------------------------------
419
    // AvatarSize::diameter / radius / font_size  (getters)
420
    // ------------------------------------------------------------------
421

            
422
    #[test]
423
    fn avatar_size_getters_return_documented_values() {
424
        assert_eq!(AvatarSize::Small.diameter(), 24);
425
        assert_eq!(AvatarSize::Medium.diameter(), 40);
426
        assert_eq!(AvatarSize::Large.diameter(), 64);
427

            
428
        assert_eq!(AvatarSize::Small.radius(), 12);
429
        assert_eq!(AvatarSize::Medium.radius(), 20);
430
        assert_eq!(AvatarSize::Large.radius(), 32);
431

            
432
        assert_eq!(AvatarSize::Small.font_size(), 11);
433
        assert_eq!(AvatarSize::Medium.font_size(), 16);
434
        assert_eq!(AvatarSize::Large.font_size(), 24);
435
    }
436

            
437
    #[test]
438
    fn avatar_size_radius_is_exactly_half_the_diameter() {
439
        // `radius()` is an integer division: an odd diameter would truncate and
440
        // the "circle" would render as a rounded square. Every variant must be even.
441
        for size in ALL_SIZES {
442
            let d = size.diameter();
443
            assert_eq!(
444
                d % 2,
445
                0,
446
                "{size:?}: diameter {d} is odd, so radius() truncates and the avatar is not a circle"
447
            );
448
            assert_eq!(size.radius() * 2, d, "{size:?}: radius must be exactly d/2");
449
        }
450
    }
451

            
452
    #[test]
453
    fn avatar_size_getters_are_positive_and_font_fits_the_circle() {
454
        for size in ALL_SIZES {
455
            assert!(size.diameter() > 0, "{size:?}: non-positive diameter");
456
            assert!(size.radius() > 0, "{size:?}: non-positive radius");
457
            assert!(size.font_size() > 0, "{size:?}: non-positive font size");
458
            assert!(
459
                size.font_size() < size.diameter(),
460
                "{size:?}: font size {} does not fit in a {}px circle",
461
                size.font_size(),
462
                size.diameter()
463
            );
464
        }
465
    }
466

            
467
    #[test]
468
    fn avatar_size_getters_are_monotonic_in_the_size_variant() {
469
        // Small < Medium < Large must hold for both the box and the text, or a
470
        // "larger" avatar could render smaller than a "smaller" one.
471
        let d: Vec<isize> = ALL_SIZES.iter().map(AvatarSize::diameter).collect();
472
        let f: Vec<isize> = ALL_SIZES.iter().map(AvatarSize::font_size).collect();
473
        assert!(d[0] < d[1] && d[1] < d[2], "diameters not increasing: {d:?}");
474
        assert!(f[0] < f[1] && f[1] < f[2], "font sizes not increasing: {f:?}");
475
    }
476

            
477
    #[test]
478
    fn avatar_size_getters_are_pure_and_default_is_medium() {
479
        assert_eq!(AvatarSize::default(), AvatarSize::Medium);
480
        assert_eq!(AvatarSize::default().diameter(), 40);
481

            
482
        // The getters take `&self` on a `Copy` enum: repeated calls (and calls
483
        // through a copy) must be side-effect free and identical.
484
        for size in ALL_SIZES {
485
            let copy = size;
486
            assert_eq!(size.diameter(), copy.diameter());
487
            assert_eq!(size.diameter(), size.diameter());
488
            assert_eq!(size.radius(), size.radius());
489
            assert_eq!(size.font_size(), size.font_size());
490
        }
491
    }
492

            
493
    // ------------------------------------------------------------------
494
    // build_avatar_style / build_image_style  (numeric)
495
    // ------------------------------------------------------------------
496

            
497
    #[test]
498
    fn build_avatar_style_box_matches_the_size_variant() {
499
        for size in ALL_SIZES {
500
            let style = build_avatar_style(size);
501
            #[allow(clippy::cast_precision_loss)]
502
            let d = size.diameter() as f32;
503
            #[allow(clippy::cast_precision_loss)]
504
            let r = size.radius() as f32;
505
            #[allow(clippy::cast_precision_loss)]
506
            let f = size.font_size() as f32;
507

            
508
            assert_eq!(width_px(&style), Some(d), "{size:?}: width != diameter");
509
            assert_eq!(height_px(&style), Some(d), "{size:?}: height != diameter");
510
            assert_eq!(font_size_px(&style), Some(f), "{size:?}: wrong font size");
511
            assert_eq!(
512
                radii_px(&style),
513
                vec![r, r, r, r],
514
                "{size:?}: all four corners must carry the same radius"
515
            );
516
        }
517
    }
518

            
519
    #[test]
520
    fn build_avatar_style_radius_is_half_the_box_so_it_renders_as_a_circle() {
521
        // The widget's whole premise: r == d/2 in the *emitted* style, not just
522
        // in the getters.
523
        for size in ALL_SIZES {
524
            let style = build_avatar_style(size);
525
            let d = width_px(&style).expect("width must be declared");
526
            for r in radii_px(&style) {
527
                assert!(
528
                    (r * 2.0 - d).abs() < f32::EPSILON,
529
                    "{size:?}: radius {r} is not half of the {d}px box"
530
                );
531
            }
532
        }
533
    }
534

            
535
    #[test]
536
    fn build_avatar_style_clips_and_centres_its_content() {
537
        for size in ALL_SIZES {
538
            let props = properties(&build_avatar_style(size));
539
            let has = |p: &CssProperty| props.contains(p);
540

            
541
            assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)));
542
            assert!(has(&CssProperty::const_flex_direction(LayoutFlexDirection::Row)));
543
            assert!(has(&CssProperty::const_justify_content(LayoutJustifyContent::Center)));
544
            assert!(has(&CssProperty::const_align_items(LayoutAlignItems::Center)));
545
            assert!(has(&CssProperty::align_self(LayoutAlignSelf::Start)));
546
            assert!(has(&CssProperty::const_text_align(StyleTextAlign::Center)));
547
            // Both axes must clip, or an image/long initials escape the circle.
548
            assert!(has(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
549
            assert!(has(&CssProperty::const_overflow_y(LayoutOverflow::Hidden)));
550
            // flex-grow: 0 — the avatar hugs its fixed diameter in a flex parent.
551
            assert!(has(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
552
        }
553
    }
554

            
555
    #[test]
556
    fn build_avatar_style_colors_are_the_documented_constants() {
557
        let props = properties(&build_avatar_style(AvatarSize::Medium));
558

            
559
        let text = props.iter().find_map(|p| match p {
560
            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
561
            _ => None,
562
        });
563
        assert_eq!(text, Some(ColorU { r: 255, g: 255, b: 255, a: 255 }));
564

            
565
        let bg = props.iter().find_map(|p| match p {
566
            CssProperty::BackgroundContent(b) => b.get_property(),
567
            _ => None,
568
        });
569
        let bg = bg.expect("a background must be declared behind the initials");
570
        assert_eq!(bg.as_ref().len(), 1, "exactly one background layer");
571
        assert_eq!(
572
            bg.as_ref()[0],
573
            StyleBackgroundContent::Color(ColorU { r: 108, g: 117, b: 125, a: 255 })
574
        );
575
        // Both colours must be fully opaque, or the initials wash out.
576
        assert_eq!(text.expect("text colour").a, 255);
577
    }
578

            
579
    #[test]
580
    fn build_avatar_style_declares_every_property_at_most_once() {
581
        // A duplicated declaration is a last-one-wins ambiguity: two `width`s
582
        // would silently make one of them dead.
583
        for size in ALL_SIZES {
584
            let props = properties(&build_avatar_style(size));
585
            let mut seen = HashSet::new();
586
            for p in &props {
587
                assert!(
588
                    seen.insert(core::mem::discriminant(p)),
589
                    "{size:?}: duplicate declaration of {p:?}"
590
                );
591
            }
592
            assert_eq!(seen.len(), props.len());
593
        }
594
    }
595

            
596
    #[test]
597
    fn build_avatar_style_properties_are_all_unconditional() {
598
        // Every declaration must apply with no `:hover`/state condition — a
599
        // conditional one would simply never paint on a stateless widget.
600
        for size in ALL_SIZES {
601
            for p in build_avatar_style(size).as_ref() {
602
                assert!(
603
                    p.apply_if.as_ref().is_empty(),
604
                    "{size:?}: {:?} is conditional on a stateless widget",
605
                    p.property
606
                );
607
            }
608
        }
609
    }
610

            
611
    #[test]
612
    fn build_avatar_style_is_deterministic_and_size_dependent() {
613
        for size in ALL_SIZES {
614
            assert_eq!(
615
                properties(&build_avatar_style(size)),
616
                properties(&build_avatar_style(size)),
617
                "{size:?}: two builds of the same size disagree"
618
            );
619
        }
620
        // Different variants must not collapse onto the same style.
621
        assert_ne!(
622
            properties(&build_avatar_style(AvatarSize::Small)),
623
            properties(&build_avatar_style(AvatarSize::Large))
624
        );
625
        assert_ne!(
626
            properties(&build_avatar_style(AvatarSize::Small)),
627
            properties(&build_avatar_style(AvatarSize::Medium))
628
        );
629
    }
630

            
631
    #[test]
632
    fn build_image_style_fills_the_circle_exactly() {
633
        for size in ALL_SIZES {
634
            let container = build_avatar_style(size);
635
            let image = build_image_style(size);
636

            
637
            // The image must be exactly as big and as round as its container,
638
            // otherwise it either leaves a gap or is clipped square at a corner.
639
            assert_eq!(width_px(&image), width_px(&container), "{size:?}: image width");
640
            assert_eq!(height_px(&image), height_px(&container), "{size:?}: image height");
641
            assert_eq!(radii_px(&image), radii_px(&container), "{size:?}: image radii");
642
            assert_eq!(radii_px(&image).len(), 4, "{size:?}: all four corners rounded");
643
            // The image must not grow past the circle in a flex row.
644
            assert!(properties(&image)
645
                .contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
646
        }
647
    }
648

            
649
    #[test]
650
    fn build_image_style_declares_every_property_once_and_unconditionally() {
651
        for size in ALL_SIZES {
652
            let style = build_image_style(size);
653
            let mut seen = HashSet::new();
654
            for p in style.as_ref() {
655
                assert!(
656
                    seen.insert(core::mem::discriminant(&p.property)),
657
                    "{size:?}: duplicate declaration of {:?}",
658
                    p.property
659
                );
660
                assert!(p.apply_if.as_ref().is_empty(), "{size:?}: conditional image property");
661
            }
662
            assert_eq!(
663
                properties(&build_image_style(size)),
664
                properties(&build_image_style(size)),
665
                "{size:?}: build_image_style is not deterministic"
666
            );
667
        }
668
    }
669

            
670
    #[test]
671
    fn every_emitted_length_is_a_finite_non_negative_px_value() {
672
        // `isize` -> `PixelValue` is the only numeric conversion in this file:
673
        // guard against a NaN/inf/negative length ever reaching the solver.
674
        for size in ALL_SIZES {
675
            for style in [build_avatar_style(size), build_image_style(size)] {
676
                let values = all_pixel_values(&style);
677
                assert!(!values.is_empty(), "{size:?}: no lengths emitted at all");
678
                for pv in values {
679
                    let n = px(&pv); // also asserts SizeMetric::Px
680
                    assert!(n.is_finite(), "{size:?}: non-finite length {n}");
681
                    assert!(n >= 0.0, "{size:?}: negative length {n}");
682
                    assert!(n <= 4096.0, "{size:?}: implausibly large length {n}");
683
                }
684
            }
685
        }
686
    }
687

            
688
    // ------------------------------------------------------------------
689
    // Avatar::create / create_with_image  (constructors)
690
    // ------------------------------------------------------------------
691

            
692
    #[test]
693
    fn create_round_trips_initials_verbatim() {
694
        // Adversarial strings: empty, combining marks, ZWJ emoji, RTL, embedded
695
        // NULs (AzString is length-based, so a NUL must NOT truncate), and a
696
        // string far longer than any real set of initials.
697
        let long = "x".repeat(100_000);
698
        let cases = [
699
            "",
700
            "AB",
701
            "e\u{0301}",                  // e + combining acute
702
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family
703
            "\u{5E9}\u{5DC}",             // RTL Hebrew
704
            "\0\0",                       // embedded NULs
705
            "  ",                         // whitespace only
706
            long.as_str(),
707
        ];
708

            
709
        for s in cases {
710
            let a = Avatar::create(AzString::from(s.to_string()));
711
            assert_eq!(a.initials.as_str(), s, "initials were not preserved verbatim");
712
            assert_eq!(a.initials.len(), s.len(), "byte length changed (NUL truncation?)");
713
            assert!(a.image.is_none(), "create() must not set an image");
714
            assert_eq!(a.size, AvatarSize::Medium);
715
            assert_eq!(properties(&a.avatar_style), properties(&build_avatar_style(AvatarSize::Medium)));
716
        }
717
    }
718

            
719
    #[test]
720
    fn create_with_image_keeps_the_image_and_empty_initials() {
721
        let img = test_image();
722
        let hash = img.get_hash();
723
        let a = Avatar::create_with_image(img);
724

            
725
        assert!(a.image.is_some());
726
        match &a.image {
727
            OptionImageRef::Some(i) => assert_eq!(i.get_hash(), hash, "a different image came back"),
728
            OptionImageRef::None => panic!("image was dropped by create_with_image"),
729
        }
730
        assert_eq!(a.initials.as_str(), "", "image avatars have empty fallback initials");
731
        assert_eq!(a.size, AvatarSize::Medium);
732
    }
733

            
734
    #[test]
735
    fn default_avatar_equals_an_empty_medium_avatar() {
736
        let d = Avatar::default();
737
        assert_eq!(d, Avatar::create(AzString::from_const_str("")));
738
        assert_eq!(d.clone(), d, "Clone must preserve equality");
739
        assert_ne!(d, Avatar::create(AzString::from_const_str("AB")));
740
        assert_ne!(
741
            Avatar::create(AzString::from_const_str("AB")),
742
            Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Large),
743
            "avatars of different sizes must not compare equal"
744
        );
745
    }
746

            
747
    // ------------------------------------------------------------------
748
    // set_image / with_image / set_size / with_size  (setters)
749
    // ------------------------------------------------------------------
750

            
751
    #[test]
752
    fn with_image_and_set_image_agree_and_keep_the_other_fields() {
753
        let base = Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Large);
754

            
755
        let mut mutated = base.clone();
756
        mutated.set_image(test_image());
757
        let built = base.clone().with_image(test_image());
758

            
759
        assert!(mutated.image.is_some() && built.image.is_some());
760
        // Setting an image must not disturb the size, the style, or the fallback text.
761
        for a in [&mutated, &built] {
762
            assert_eq!(a.size, AvatarSize::Large);
763
            assert_eq!(a.initials.as_str(), "AB", "set_image must keep the fallback initials");
764
            assert_eq!(properties(&a.avatar_style), properties(&base.avatar_style));
765
        }
766
    }
767

            
768
    #[test]
769
    fn set_image_replaces_a_previous_image_rather_than_stacking() {
770
        let first = test_image();
771
        let second = test_image();
772
        let (h1, h2) = (first.get_hash(), second.get_hash());
773
        assert_ne!(h1, h2, "fixture bug: the two images must be distinguishable");
774

            
775
        let mut a = Avatar::create_with_image(first);
776
        a.set_image(second);
777
        match &a.image {
778
            OptionImageRef::Some(i) => assert_eq!(i.get_hash(), h2, "the second image must win"),
779
            OptionImageRef::None => panic!("image lost"),
780
        }
781
    }
782

            
783
    #[test]
784
    fn set_size_recomputes_the_style_without_growing_it() {
785
        // A `push`-instead-of-replace bug would make the style vec grow on every
786
        // call and leave stale (earlier-size) declarations behind.
787
        let mut a = Avatar::create(AzString::from_const_str("AB"));
788
        let expected_len = build_avatar_style(AvatarSize::Medium).as_ref().len();
789

            
790
        for round in 0..50 {
791
            let size = ALL_SIZES[round % ALL_SIZES.len()];
792
            a.set_size(size);
793

            
794
            assert_eq!(a.size, size, "round {round}: size field not updated");
795
            assert_eq!(
796
                a.avatar_style.as_ref().len(),
797
                expected_len,
798
                "round {round}: style vec changed length — stale declarations?"
799
            );
800
            assert_eq!(
801
                properties(&a.avatar_style),
802
                properties(&build_avatar_style(size)),
803
                "round {round}: style does not match the freshly built one"
804
            );
805
            assert_eq!(a.initials.as_str(), "AB", "round {round}: set_size ate the initials");
806
        }
807
    }
808

            
809
    #[test]
810
    fn set_size_keeps_the_image() {
811
        let mut a = Avatar::create_with_image(test_image());
812
        a.set_size(AvatarSize::Small);
813
        assert!(a.image.is_some(), "set_size must not drop the image");
814
        assert_eq!(width_px(&a.avatar_style), Some(24.0));
815
    }
816

            
817
    #[test]
818
    fn with_size_is_last_call_wins_and_matches_set_size() {
819
        let chained = Avatar::create(AzString::from_const_str("AB"))
820
            .with_size(AvatarSize::Large)
821
            .with_size(AvatarSize::Small)
822
            .with_size(AvatarSize::Medium);
823

            
824
        let mut mutated = Avatar::create(AzString::from_const_str("AB"));
825
        mutated.set_size(AvatarSize::Large);
826
        mutated.set_size(AvatarSize::Small);
827
        mutated.set_size(AvatarSize::Medium);
828

            
829
        assert_eq!(chained, mutated, "builder and mutator must agree");
830
        assert_eq!(chained.size, AvatarSize::Medium);
831
        assert_eq!(
832
            properties(&chained.avatar_style),
833
            properties(&build_avatar_style(AvatarSize::Medium))
834
        );
835
    }
836

            
837
    // ------------------------------------------------------------------
838
    // swap_with_default
839
    // ------------------------------------------------------------------
840

            
841
    #[test]
842
    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
843
        let mut a = Avatar::create(AzString::from_const_str("AB"))
844
            .with_size(AvatarSize::Large)
845
            .with_image(test_image());
846

            
847
        let taken = a.swap_with_default();
848

            
849
        // The returned value is the *original*, intact.
850
        assert_eq!(taken.initials.as_str(), "AB");
851
        assert_eq!(taken.size, AvatarSize::Large);
852
        assert!(taken.image.is_some());
853
        assert_eq!(properties(&taken.avatar_style), properties(&build_avatar_style(AvatarSize::Large)));
854

            
855
        // What is left behind is a *default* avatar — in particular its style
856
        // must be Medium's, not a stale Large one.
857
        assert_eq!(a, Avatar::default());
858
        assert!(a.image.is_none(), "the image must not survive in the emptied avatar");
859
        assert_eq!(a.initials.as_str(), "");
860
        assert_eq!(a.size, AvatarSize::Medium);
861
        assert_eq!(properties(&a.avatar_style), properties(&build_avatar_style(AvatarSize::Medium)));
862
    }
863

            
864
    #[test]
865
    fn swap_with_default_is_idempotent_on_an_already_default_avatar() {
866
        let mut a = Avatar::default();
867
        let first = a.swap_with_default();
868
        let second = a.swap_with_default();
869
        assert_eq!(first, Avatar::default());
870
        assert_eq!(second, Avatar::default());
871
        assert_eq!(a, Avatar::default());
872
    }
873

            
874
    // ------------------------------------------------------------------
875
    // Avatar::dom
876
    // ------------------------------------------------------------------
877

            
878
    #[test]
879
    fn dom_of_an_initials_avatar_is_a_circle_wrapping_the_text() {
880
        for size in ALL_SIZES {
881
            let avatar = Avatar::create(AzString::from_const_str("AB")).with_size(size);
882
            let expected = properties(&avatar.avatar_style);
883
            let dom = avatar.dom();
884

            
885
            assert!(has_class(&dom, "__azul-native-avatar"), "{size:?}: missing root class");
886
            assert_eq!(
887
                inline_properties(&dom),
888
                expected,
889
                "{size:?}: the container lost its computed style"
890
            );
891

            
892
            let child = only_child(&dom);
893
            assert!(has_class(child, "__azul-native-avatar-initials"));
894
            assert_eq!(text_of(child), Some("AB"), "{size:?}: expected `p > text`");
895
        }
896
    }
897

            
898
    #[test]
899
    fn dom_of_an_image_avatar_renders_the_image_and_drops_the_initials() {
900
        for size in ALL_SIZES {
901
            let img = test_image();
902
            let hash = img.get_hash();
903
            // The initials are only a *fallback*: with an image set they must not
904
            // be rendered as a second child on top of the image.
905
            let avatar = Avatar::create(AzString::from_const_str("AB"))
906
                .with_size(size)
907
                .with_image(img);
908
            let dom = avatar.dom();
909

            
910
            let child = only_child(&dom);
911
            assert!(has_class(child, "__azul-native-avatar-image"), "{size:?}: missing image class");
912
            assert!(
913
                !has_class(child, "__azul-native-avatar-initials"),
914
                "{size:?}: initials rendered on top of the image"
915
            );
916
            match child.root.get_node_type() {
917
                NodeType::Image(i) => assert_eq!(i.as_ref().get_hash(), hash, "{size:?}: wrong image"),
918
                other => panic!("{size:?}: expected an image child, got {other:?}"),
919
            }
920
            assert_eq!(
921
                inline_properties(child),
922
                properties(&build_image_style(size)),
923
                "{size:?}: the image child does not carry the matching circular style"
924
            );
925
        }
926
    }
927

            
928
    #[test]
929
    fn dom_preserves_adversarial_initials_verbatim() {
930
        let long = "\u{1F600}".repeat(10_000); // 40 000 bytes of emoji
931
        for s in ["", "\0", "e\u{0301}", "\u{5E9}\u{5DC}", long.as_str()] {
932
            let dom = Avatar::create(AzString::from(s.to_string())).dom();
933
            let child = only_child(&dom);
934
            match child.children.as_ref() {
935
                [only] => match only.root.get_node_type() {
936
                    NodeType::Text(t) => {
937
                        assert_eq!(t.as_ref().as_str(), s, "text node mangled the initials");
938
                        assert_eq!(t.as_ref().len(), s.len(), "text node changed the byte length");
939
                    }
940
                    other => panic!("expected a text child, got {other:?}"),
941
                },
942
                other => panic!("expected `p > text`, got {} grandchildren", other.len()),
943
            }
944
        }
945
    }
946

            
947
    #[test]
948
    fn dom_and_the_from_impl_agree() {
949
        let avatar = Avatar::create(AzString::from_const_str("AB")).with_size(AvatarSize::Small);
950
        assert_eq!(Dom::from(avatar.clone()), avatar.dom());
951
    }
952

            
953
    #[test]
954
    fn dom_geometry_is_consistent_between_container_and_image_after_set_size() {
955
        // Through the supported API (`set_size` / `with_size`) the container and
956
        // the image must always resolve to the same diameter.
957
        for size in ALL_SIZES {
958
            let dom = Avatar::create_with_image(test_image()).with_size(size).dom();
959
            #[allow(clippy::cast_precision_loss)]
960
            let d = size.diameter() as f32;
961

            
962
            let container: Vec<CssProperty> = inline_properties(&dom);
963
            let child: Vec<CssProperty> = inline_properties(only_child(&dom));
964
            let width_of = |props: &[CssProperty]| {
965
                props.iter().find_map(|p| match p {
966
                    CssProperty::Width(w) => match w.get_property()? {
967
                        LayoutWidth::Px(pv) => Some(px(pv)),
968
                        _ => None,
969
                    },
970
                    _ => None,
971
                })
972
            };
973
            assert_eq!(width_of(&container), Some(d), "{size:?}: container width");
974
            assert_eq!(width_of(&child), Some(d), "{size:?}: image width");
975
        }
976
    }
977

            
978
    #[test]
979
    fn assigning_the_size_field_directly_desyncs_the_container_from_the_image() {
980
        // `size` and `avatar_style` are both public, and `dom()` reads the image
981
        // geometry from `size` while the container keeps the *stored* style. So a
982
        // direct field write (bypassing `set_size`) silently produces an avatar
983
        // whose image is a different diameter than its circle. Pinned here as the
984
        // current behaviour — `set_size` is the only correct path.
985
        let mut a = Avatar::create_with_image(test_image());
986
        a.size = AvatarSize::Large; // NOT set_size: the style is not recomputed
987
        let dom = a.dom();
988

            
989
        assert_eq!(
990
            width_px(&build_avatar_style(AvatarSize::Medium)),
991
            Some(40.0),
992
            "fixture assumption: the stored style is still Medium's"
993
        );
994
        let container = inline_properties(&dom);
995
        let container_width = container.iter().find_map(|p| match p {
996
            CssProperty::Width(w) => match w.get_property()? {
997
                LayoutWidth::Px(pv) => Some(px(pv)),
998
                _ => None,
999
            },
            _ => None,
        });
        assert_eq!(container_width, Some(40.0), "container still uses the stored Medium style");
        assert_eq!(
            inline_properties(only_child(&dom)),
            properties(&build_image_style(AvatarSize::Large)),
            "the image, however, follows the freshly assigned `size` field"
        );
    }
}