1
//! Spinner / activity-indicator widget — a small indeterminate "busy" ring. A
2
//! stateless single styled node (a near-clone of the leaf-node construction of
3
//! [`crate::widgets::badge::Badge`] / [`crate::widgets::progressbar::ProgressBar`]),
4
//! drawn as a circular ring whose three sides use a faint "track" colour and
5
//! whose top side uses a solid accent colour — the classic spinner look frozen
6
//! mid-rotation.
7
//!
8
//! ## PARTIAL — STATIC ONLY (no spin animation). See `TODO2` below.
9
//!
10
//! TODO2: this spinner is **static** — it shows the indeterminate ring shape but
11
//! does NOT rotate. Azul has no declarative CSS animation: there is no
12
//! `@keyframes` / `animation` / `transition` CSS property (`css/src/props` only
13
//! exposes one-shot `Transform`/`TransformOrigin` GPU props and the system-level
14
//! `AnimationMetrics` toggle; `props/basic/animation.rs` is SVG-curve
15
//! interpolation maths, not a style-driven keyframe engine). Producing real
16
//! motion would require a timer-driven `Update` loop that re-issues a rotating
17
//! `CssProperty::Transform` each tick (the same mechanism scroll-smoothing uses),
18
//! driven from the host app — there is no widget-local way to start such a timer
19
//! at DOM-build time. Rather than fake motion that cannot be produced, the ring
20
//! is rendered statically; a future revision can add the timer-driven rotation
21
//! once a widget-owned animation hook exists. (Compile-verified; not GUI-verified.)
22
//!
23
//! Key types: [`Spinner`].
24

            
25
use azul_core::dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec};
26
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
27
use azul_css::{
28
    props::{
29
        basic::{color::ColorU, *},
30
        layout::{LayoutAlignSelf, LayoutFlexGrow, LayoutWidth, LayoutHeight},
31
        property::{CssProperty, *},
32
        style::{LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius},
33
    },
34
    AzString,
35
};
36

            
37
static SPINNER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-spinner"))];
38

            
39
/// Default ring diameter, in logical px.
40
const DEFAULT_SIZE: isize = 24;
41
/// Faint "track" colour for the three inactive sides (#d0d4d9).
42
const DEFAULT_TRACK_COLOR: ColorU = ColorU { r: 208, g: 212, b: 217, a: 255 };
43
/// Solid accent colour for the active (top) arc (#0d6efd, accent blue).
44
const DEFAULT_ACCENT_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
45

            
46
/// An indeterminate busy-indicator ring. Stateless; renders a single styled
47
/// node. **Static** — the ring shows the spinner shape but does not rotate
48
/// (see the module-level `TODO2`).
49
#[derive(Debug, Clone, PartialEq, Eq)]
50
#[repr(C)]
51
pub struct Spinner {
52
    /// The ring diameter, in logical px.
53
    pub size: isize,
54
    /// Colour of the active (top) arc.
55
    pub color: ColorU,
56
    /// Colour of the three inactive ("track") sides.
57
    pub track_color: ColorU,
58
    /// The computed inline style for the ring.
59
    pub spinner_style: CssPropertyWithConditionsVec,
60
}
61

            
62
/// Builds the ring style for the given diameter and colours. All three are
63
/// instance-dependent, so the style is built at runtime per the recipe's
64
/// "runtime vec when param-dependent" path (see `badge::build_badge_style`).
65
1923
fn build_spinner_style(size: isize, color: ColorU, track_color: ColorU) -> CssPropertyWithConditionsVec {
66
    // Ring thickness scales with the diameter (min 2px); radius = size/2 → circle.
67
1923
    let border_width = (size / 8).max(2);
68
1923
    let radius = size / 2;
69
1923
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
70
        // Hug its own size inside a flex parent rather than stretch/grow.
71
1923
        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
72
1923
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
73
            0,
74
        ))),
75
1923
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(size))),
76
1923
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(size))),
77
        // border: <border_width>px solid — three sides track, top accent.
78
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
79
1923
            LayoutBorderTopWidth::const_px(border_width),
80
        )),
81
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
82
1923
            LayoutBorderBottomWidth::const_px(border_width),
83
        )),
84
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
85
1923
            LayoutBorderLeftWidth::const_px(border_width),
86
        )),
87
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
88
1923
            LayoutBorderRightWidth::const_px(border_width),
89
        )),
90
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
91
1923
            inner: BorderStyle::Solid,
92
1923
        })),
93
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
94
1923
            StyleBorderBottomStyle {
95
1923
                inner: BorderStyle::Solid,
96
1923
            },
97
        )),
98
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
99
1923
            inner: BorderStyle::Solid,
100
1923
        })),
101
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
102
1923
            StyleBorderRightStyle {
103
1923
                inner: BorderStyle::Solid,
104
1923
            },
105
        )),
106
        // top = accent (the visible "arc"); other three = faint track.
107
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
108
1923
            inner: color,
109
1923
        })),
110
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
111
1923
            StyleBorderBottomColor { inner: track_color },
112
        )),
113
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
114
1923
            inner: track_color,
115
1923
        })),
116
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
117
1923
            StyleBorderRightColor { inner: track_color },
118
        )),
119
        // border-radius: size/2 → a circle.
120
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
121
1923
            StyleBorderTopLeftRadius::const_px(radius),
122
        )),
123
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
124
1923
            StyleBorderTopRightRadius::const_px(radius),
125
        )),
126
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
127
1923
            StyleBorderBottomLeftRadius::const_px(radius),
128
        )),
129
1923
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
130
1923
            StyleBorderBottomRightRadius::const_px(radius),
131
        )),
132
    ])
133
1923
}
134

            
135
impl Spinner {
136
    /// Creates a new spinner with the default size (24px) and accent colour.
137
    #[inline]
138
63
    #[must_use] pub fn create() -> Self {
139
63
        Self::with_size(DEFAULT_SIZE)
140
63
    }
141

            
142
    /// Creates a new spinner with the given diameter (logical px) and the
143
    /// default colours.
144
    #[inline]
145
153
    #[must_use] pub fn with_size(size: isize) -> Self {
146
153
        Self {
147
153
            size,
148
153
            color: DEFAULT_ACCENT_COLOR,
149
153
            track_color: DEFAULT_TRACK_COLOR,
150
153
            spinner_style: build_spinner_style(size, DEFAULT_ACCENT_COLOR, DEFAULT_TRACK_COLOR),
151
153
        }
152
153
    }
153

            
154
    /// Sets the ring diameter (logical px), recomputing the style.
155
    #[inline]
156
155
    pub fn set_size(&mut self, size: isize) {
157
155
        self.size = size;
158
155
        self.spinner_style = build_spinner_style(size, self.color, self.track_color);
159
155
    }
160

            
161
    /// Builder-style setter for the ring diameter.
162
    #[inline]
163
21
    #[must_use] pub fn with_spinner_size(mut self, size: isize) -> Self {
164
21
        self.set_size(size);
165
21
        self
166
21
    }
167

            
168
    /// Sets the active-arc colour, recomputing the style.
169
    #[inline]
170
181
    pub fn set_color(&mut self, color: ColorU) {
171
181
        self.color = color;
172
181
        self.spinner_style = build_spinner_style(self.size, color, self.track_color);
173
181
    }
174

            
175
    /// Builder-style setter for the active-arc colour.
176
    #[inline]
177
79
    #[must_use] pub fn with_color(mut self, color: ColorU) -> Self {
178
79
        self.set_color(color);
179
79
        self
180
79
    }
181

            
182
    /// Sets the inactive "track" colour, recomputing the style.
183
    #[inline]
184
179
    pub fn set_track_color(&mut self, track_color: ColorU) {
185
179
        self.track_color = track_color;
186
179
        self.spinner_style = build_spinner_style(self.size, self.color, track_color);
187
179
    }
188

            
189
    /// Builder-style setter for the inactive "track" colour.
190
    #[inline]
191
76
    #[must_use] pub fn with_track_color(mut self, track_color: ColorU) -> Self {
192
76
        self.set_track_color(track_color);
193
76
        self
194
76
    }
195

            
196
    /// Replaces `self` with a default spinner and returns the original.
197
    #[inline]
198
4
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
199
4
        let mut s = Self::create();
200
4
        core::mem::swap(&mut s, self);
201
4
        s
202
4
    }
203

            
204
    /// Converts this spinner into a single DOM node with the
205
    /// `__azul-native-spinner` class.
206
    #[inline]
207
55
    #[must_use] pub fn dom(self) -> Dom {
208
55
        Dom::create_div()
209
55
            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
210
55
            .with_css_props(self.spinner_style)
211
55
    }
212
}
213

            
214
impl Default for Spinner {
215
1
    fn default() -> Self {
216
1
        Self::create()
217
1
    }
218
}
219

            
220
impl From<Spinner> for Dom {
221
1
    fn from(s: Spinner) -> Self {
222
1
        s.dom()
223
1
    }
224
}
225

            
226
#[cfg(test)]
227
#[allow(
228
    clippy::too_many_lines,
229
    clippy::unreadable_literal,
230
    clippy::cast_possible_truncation,
231
    clippy::cast_precision_loss,
232
    clippy::float_cmp
233
)]
234
mod autotest_generated {
235
    use azul_core::dom::NodeType;
236

            
237
    use super::*;
238

            
239
    // ------------------------------------------------------------------
240
    // Helpers
241
    // ------------------------------------------------------------------
242

            
243
    /// The number of declarations `build_spinner_style` is supposed to emit.
244
    const DECLARATIONS: usize = 20;
245

            
246
    /// Every property the ring declares, in source order. A missing side (or a
247
    /// side declared twice) is the difference between a ring and a solid box.
248
    const EXPECTED_ORDER: [CssPropertyType; DECLARATIONS] = [
249
        CssPropertyType::AlignSelf,
250
        CssPropertyType::FlexGrow,
251
        CssPropertyType::Width,
252
        CssPropertyType::Height,
253
        CssPropertyType::BorderTopWidth,
254
        CssPropertyType::BorderBottomWidth,
255
        CssPropertyType::BorderLeftWidth,
256
        CssPropertyType::BorderRightWidth,
257
        CssPropertyType::BorderTopStyle,
258
        CssPropertyType::BorderBottomStyle,
259
        CssPropertyType::BorderLeftStyle,
260
        CssPropertyType::BorderRightStyle,
261
        CssPropertyType::BorderTopColor,
262
        CssPropertyType::BorderBottomColor,
263
        CssPropertyType::BorderLeftColor,
264
        CssPropertyType::BorderRightColor,
265
        CssPropertyType::BorderTopLeftRadius,
266
        CssPropertyType::BorderTopRightRadius,
267
        CssPropertyType::BorderBottomLeftRadius,
268
        CssPropertyType::BorderBottomRightRadius,
269
    ];
270

            
271
    /// `FloatValue` stores `value * FP_PRECISION_MULTIPLIER` as an `isize`, so a
272
    /// whole-pixel size only survives while `|size| <= isize::MAX / 1000`.
273
    const FP_SCALE: isize = 1000;
274

            
275
    /// The largest / smallest diameters that still fit the fixed-point encoding.
276
    /// `isize::MIN / 1000` truncates toward zero, so it scales back to
277
    /// `-9223372036854775000`, one step inside `isize::MIN`.
278
    const MAX_ENCODABLE_SIZE: isize = isize::MAX / FP_SCALE;
279
    const MIN_ENCODABLE_SIZE: isize = isize::MIN / FP_SCALE;
280

            
281
    /// Diameters that must all build a style without panicking: the degenerate
282
    /// small ones (where the 2px floor is thicker than the box), the ordinary
283
    /// ones, negatives (nothing in the widget rejects them), and both ends of
284
    /// the encodable range.
285
    const SAFE_SIZES: [isize; 16] = [
286
        0,
287
        1,
288
        2,
289
        3,
290
        4,
291
        7,
292
        8,
293
        15,
294
        16,
295
        24,
296
        1_000,
297
        -1,
298
        -3,
299
        -24,
300
        MAX_ENCODABLE_SIZE,
301
        MIN_ENCODABLE_SIZE,
302
    ];
303

            
304
    /// Colours that are trivially distinguishable in a failure message, plus the
305
    /// fully transparent one (alpha is carried untouched, so it must survive).
306
    const RED: ColorU = ColorU { r: 255, g: 0, b: 0, a: 255 };
307
    const GREEN: ColorU = ColorU { r: 0, g: 255, b: 0, a: 255 };
308
    const GHOST: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
309

            
310
    /// The declared properties of a style vec, in declaration order.
311
    fn props(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
312
        v.as_slice().iter().map(|p| p.property.clone()).collect()
313
    }
314

            
315
    /// The first property matching `f`, or `None` if the style never declares it.
316
    fn find<T>(v: &CssPropertyWithConditionsVec, f: impl Fn(&CssProperty) -> Option<T>) -> Option<T> {
317
        v.as_slice().iter().find_map(|p| f(&p.property))
318
    }
319

            
320
    /// The raw fixed-point encoding of a length — the value that actually
321
    /// survives, without a second lossy round trip through `get()`.
322
    fn raw(pv: PixelValue) -> isize {
323
        pv.number.number()
324
    }
325

            
326
    fn width(v: &CssPropertyWithConditionsVec) -> PixelValue {
327
        find(v, |p| match p {
328
            CssProperty::Width(x) => match x.get_property() {
329
                Some(LayoutWidth::Px(pv)) => Some(*pv),
330
                other => panic!("the ring must size in absolute lengths, got {other:?}"),
331
            },
332
            _ => None,
333
        })
334
        .expect("the ring must declare a width")
335
    }
336

            
337
    fn height(v: &CssPropertyWithConditionsVec) -> PixelValue {
338
        find(v, |p| match p {
339
            CssProperty::Height(x) => match x.get_property() {
340
                Some(LayoutHeight::Px(pv)) => Some(*pv),
341
                other => panic!("the ring must size in absolute lengths, got {other:?}"),
342
            },
343
            _ => None,
344
        })
345
        .expect("the ring must declare a height")
346
    }
347

            
348
    /// Border widths in `[top, right, bottom, left]` order.
349
    fn border_widths(v: &CssPropertyWithConditionsVec) -> [PixelValue; 4] {
350
        [
351
            find(v, |p| match p {
352
                CssProperty::BorderTopWidth(x) => x.get_property().map(|x| x.inner),
353
                _ => None,
354
            }),
355
            find(v, |p| match p {
356
                CssProperty::BorderRightWidth(x) => x.get_property().map(|x| x.inner),
357
                _ => None,
358
            }),
359
            find(v, |p| match p {
360
                CssProperty::BorderBottomWidth(x) => x.get_property().map(|x| x.inner),
361
                _ => None,
362
            }),
363
            find(v, |p| match p {
364
                CssProperty::BorderLeftWidth(x) => x.get_property().map(|x| x.inner),
365
                _ => None,
366
            }),
367
        ]
368
        .map(|o| o.expect("the ring must declare all four border widths"))
369
    }
370

            
371
    /// Border colours in `[top, right, bottom, left]` order.
372
    fn border_colors(v: &CssPropertyWithConditionsVec) -> [ColorU; 4] {
373
        [
374
            find(v, |p| match p {
375
                CssProperty::BorderTopColor(x) => x.get_property().map(|x| x.inner),
376
                _ => None,
377
            }),
378
            find(v, |p| match p {
379
                CssProperty::BorderRightColor(x) => x.get_property().map(|x| x.inner),
380
                _ => None,
381
            }),
382
            find(v, |p| match p {
383
                CssProperty::BorderBottomColor(x) => x.get_property().map(|x| x.inner),
384
                _ => None,
385
            }),
386
            find(v, |p| match p {
387
                CssProperty::BorderLeftColor(x) => x.get_property().map(|x| x.inner),
388
                _ => None,
389
            }),
390
        ]
391
        .map(|o| o.expect("the ring must declare all four border colours"))
392
    }
393

            
394
    /// Border styles in `[top, right, bottom, left]` order.
395
    fn border_styles(v: &CssPropertyWithConditionsVec) -> [BorderStyle; 4] {
396
        [
397
            find(v, |p| match p {
398
                CssProperty::BorderTopStyle(x) => x.get_property().map(|x| x.inner),
399
                _ => None,
400
            }),
401
            find(v, |p| match p {
402
                CssProperty::BorderRightStyle(x) => x.get_property().map(|x| x.inner),
403
                _ => None,
404
            }),
405
            find(v, |p| match p {
406
                CssProperty::BorderBottomStyle(x) => x.get_property().map(|x| x.inner),
407
                _ => None,
408
            }),
409
            find(v, |p| match p {
410
                CssProperty::BorderLeftStyle(x) => x.get_property().map(|x| x.inner),
411
                _ => None,
412
            }),
413
        ]
414
        .map(|o| o.expect("the ring must declare all four border styles"))
415
    }
416

            
417
    /// Corner radii in `[top-left, top-right, bottom-left, bottom-right]` order.
418
    fn radii(v: &CssPropertyWithConditionsVec) -> [PixelValue; 4] {
419
        [
420
            find(v, |p| match p {
421
                CssProperty::BorderTopLeftRadius(x) => x.get_property().map(|x| x.inner),
422
                _ => None,
423
            }),
424
            find(v, |p| match p {
425
                CssProperty::BorderTopRightRadius(x) => x.get_property().map(|x| x.inner),
426
                _ => None,
427
            }),
428
            find(v, |p| match p {
429
                CssProperty::BorderBottomLeftRadius(x) => x.get_property().map(|x| x.inner),
430
                _ => None,
431
            }),
432
            find(v, |p| match p {
433
                CssProperty::BorderBottomRightRadius(x) => x.get_property().map(|x| x.inner),
434
                _ => None,
435
            }),
436
        ]
437
        .map(|o| o.expect("the ring must declare all four corner radii"))
438
    }
439

            
440
    /// Every absolute length the style declares — width, height, the four border
441
    /// widths and the four radii.
442
    fn lengths(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
443
        let mut out = vec![width(v), height(v)];
444
        out.extend(border_widths(v));
445
        out.extend(radii(v));
446
        out
447
    }
448

            
449
    /// The properties a built DOM node carries inline, in declaration order.
450
    fn dom_props(dom: &Dom) -> Vec<CssProperty> {
451
        dom.root
452
            .style
453
            .iter_inline_properties()
454
            .map(|(p, _)| p.clone())
455
            .collect()
456
    }
457

            
458
    fn dom_classes(dom: &Dom) -> Vec<String> {
459
        dom.root
460
            .get_ids_and_classes()
461
            .as_ref()
462
            .iter()
463
            .filter_map(|c| match c {
464
                Class(s) => Some(s.as_str().to_string()),
465
                IdOrClass::Id(_) => None,
466
            })
467
            .collect()
468
    }
469

            
470
    // ==================================================================
471
    // build_spinner_style — shape of the emitted style
472
    // ==================================================================
473

            
474
    #[test]
475
    fn build_spinner_style_declares_every_side_exactly_once() {
476
        // A ring is four independently-declared sides. A dropped or duplicated
477
        // declaration silently turns the spinner into a box (or a solid disc).
478
        for size in SAFE_SIZES {
479
            let style = build_spinner_style(size, RED, GREEN);
480
            let types: Vec<CssPropertyType> =
481
                props(&style).iter().map(CssProperty::get_type).collect();
482

            
483
            assert_eq!(style.len(), DECLARATIONS, "declaration count changed for {size}");
484
            assert_eq!(types, EXPECTED_ORDER.to_vec(), "declaration order changed for {size}");
485

            
486
            let mut sorted = types.clone();
487
            sorted.sort_unstable();
488
            sorted.dedup();
489
            assert_eq!(sorted.len(), DECLARATIONS, "a property is declared twice for {size}");
490
        }
491
    }
492

            
493
    #[test]
494
    fn build_spinner_style_declarations_are_all_unconditional() {
495
        // `simple()` means "no @media/@os/:hover guard". A condition sneaking in
496
        // would make the ring vanish on some platforms only.
497
        let style = build_spinner_style(24, RED, GREEN);
498
        for p in style.as_slice() {
499
            assert!(
500
                p.apply_if.is_empty(),
501
                "{:?} became conditional: {:?}",
502
                p.property.get_type(),
503
                p.apply_if,
504
            );
505
        }
506
    }
507

            
508
    #[test]
509
    fn build_spinner_style_is_deterministic() {
510
        for size in SAFE_SIZES {
511
            assert_eq!(
512
                build_spinner_style(size, RED, GREEN),
513
                build_spinner_style(size, RED, GREEN),
514
                "two identical calls produced different styles for {size}",
515
            );
516
        }
517
    }
518

            
519
    // ==================================================================
520
    // build_spinner_style — numeric edges
521
    // ==================================================================
522

            
523
    #[test]
524
    fn build_spinner_style_at_zero_size() {
525
        // 0 / 8 = 0, so the `.max(2)` floor is what keeps the border declarable;
526
        // the radius collapses to 0 and the box to 0x0.
527
        let style = build_spinner_style(0, RED, GREEN);
528

            
529
        assert_eq!(raw(width(&style)), 0);
530
        assert_eq!(raw(height(&style)), 0);
531
        for bw in border_widths(&style) {
532
            assert_eq!(raw(bw), 2 * FP_SCALE, "the 2px border floor stopped applying at size 0");
533
        }
534
        for r in radii(&style) {
535
            assert_eq!(raw(r), 0, "a zero-diameter ring must have a zero radius");
536
        }
537
    }
538

            
539
    #[test]
540
    fn border_width_is_an_eighth_of_the_size_with_a_two_px_floor() {
541
        // `(size / 8).max(2)`: integer division truncates toward zero, and every
542
        // negative eighth is swallowed by the floor.
543
        for size in (-256_isize..=256).chain(SAFE_SIZES) {
544
            let style = build_spinner_style(size, RED, GREEN);
545
            let expected = (size / 8).max(2);
546

            
547
            for bw in border_widths(&style) {
548
                assert_eq!(
549
                    raw(bw),
550
                    expected * FP_SCALE,
551
                    "border width wrong for size {size}",
552
                );
553
                assert!(raw(bw) >= 2 * FP_SCALE, "border width dropped below 2px for {size}");
554
            }
555
        }
556
    }
557

            
558
    #[test]
559
    fn radius_is_half_the_size_truncated_toward_zero() {
560
        // `size / 2` truncates, so odd diameters get a radius half a pixel short
561
        // of a perfect circle — deterministic, and identical on all four corners.
562
        for size in (-256_isize..=256).chain(SAFE_SIZES) {
563
            let style = build_spinner_style(size, RED, GREEN);
564
            let corners = radii(&style);
565

            
566
            for r in corners {
567
                assert_eq!(raw(r), (size / 2) * FP_SCALE, "radius wrong for size {size}");
568
            }
569
            assert!(
570
                corners.iter().all(|r| *r == corners[0]),
571
                "the four corners disagree for size {size}: {corners:?}",
572
            );
573
        }
574
    }
575

            
576
    #[test]
577
    fn negative_sizes_pass_straight_through_unclamped() {
578
        // Nothing rejects or clamps a negative diameter: the box and the radius
579
        // both go negative while the border keeps its 2px floor. Pinned because
580
        // it is the *only* documented behaviour — if the widget ever starts
581
        // clamping to 0, this flips loudly rather than silently changing layout.
582
        let style = build_spinner_style(-24, RED, GREEN);
583

            
584
        assert_eq!(raw(width(&style)), -24 * FP_SCALE);
585
        assert_eq!(raw(height(&style)), -24 * FP_SCALE);
586
        assert_eq!(raw(border_widths(&style)[0]), 2 * FP_SCALE);
587
        assert_eq!(raw(radii(&style)[0]), -12 * FP_SCALE);
588
    }
589

            
590
    #[test]
591
    fn the_ring_is_thicker_than_its_box_only_below_four_px() {
592
        // Below 4px the 2px-per-side floor eats more than the whole diameter, so
593
        // the "ring" degenerates into a filled blob. Above it, `size / 8` keeps
594
        // the two borders at a quarter of the box at most.
595
        for size in 0_isize..=64 {
596
            let style = build_spinner_style(size, RED, GREEN);
597
            let total = 2 * (raw(border_widths(&style)[0]) / FP_SCALE);
598

            
599
            if size >= 4 {
600
                assert!(total <= size, "size {size}: borders ({total}px) overflow the box");
601
            } else {
602
                assert!(total > size, "size {size}: expected the degenerate 2px-floor ring");
603
            }
604
        }
605
    }
606

            
607
    #[test]
608
    fn every_length_is_an_absolute_pixel() {
609
        // A relative unit here would resolve against the parent font or box and
610
        // either vanish or blow up — the ring must be self-contained.
611
        for size in SAFE_SIZES {
612
            let style = build_spinner_style(size, RED, GREEN);
613
            for length in lengths(&style) {
614
                assert_eq!(
615
                    length.metric,
616
                    SizeMetric::Px,
617
                    "size {size} produced a relative length: {length:?}",
618
                );
619
            }
620
        }
621
    }
622

            
623
    #[test]
624
    fn size_round_trips_through_the_fixed_point_encoding() {
625
        // encode == decode: the diameter goes in as an `isize` and must come back
626
        // out of the style unchanged, including at both ends of the range.
627
        for size in SAFE_SIZES {
628
            let style = build_spinner_style(size, RED, GREEN);
629

            
630
            assert_eq!(raw(width(&style)), size * FP_SCALE, "width encoding lost {size}");
631
            assert_eq!(raw(width(&style)) / FP_SCALE, size, "width did not round-trip for {size}");
632
            assert_eq!(raw(height(&style)) / FP_SCALE, size, "height did not round-trip for {size}");
633

            
634
            // The `f32` view is only exact for values a float can hold.
635
            if size.abs() <= 1_000 {
636
                assert_eq!(
637
                    width(&style).number.get(),
638
                    size as f32,
639
                    "float view wrong for {size}",
640
                );
641
            }
642
        }
643
    }
644

            
645
    #[test]
646
    fn the_edges_of_the_encodable_range_do_not_overflow() {
647
        // `isize::MAX / 1000` is the largest whole-pixel diameter `const_px` can
648
        // scale without wrapping; one more is the overflow pinned below.
649
        for size in [MAX_ENCODABLE_SIZE, MIN_ENCODABLE_SIZE] {
650
            let style = build_spinner_style(size, RED, GREEN);
651

            
652
            assert_eq!(style.len(), DECLARATIONS);
653
            assert_eq!(raw(width(&style)), size * FP_SCALE);
654
            assert_eq!(raw(radii(&style)[0]), (size / 2) * FP_SCALE);
655
            assert_eq!(raw(border_widths(&style)[0]), (size / 8).max(2) * FP_SCALE);
656
        }
657
    }
658

            
659
    #[cfg(panic = "unwind")]
660
    #[test]
661
    fn sizes_beyond_the_encodable_range_are_not_saturated() {
662
        use std::{
663
            hint::black_box,
664
            panic::{catch_unwind, AssertUnwindSafe},
665
        };
666

            
667
        // LATENT BUG, pinned: `PixelValue::const_px` multiplies by 1000 with a
668
        // plain `*`, so any diameter above `isize::MAX / 1000` (~9.2e15) either
669
        // panics (overflow checks on: `Spinner::with_size(isize::MAX)` kills a
670
        // debug build) or wraps to a garbage length (checks off) — it never
671
        // saturates. Asserted against a probe of the *current* profile so the
672
        // test is profile-independent; adding saturation flips it loudly.
673
        let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
674
            let big = black_box(isize::MAX);
675
            let _ = black_box(big * FP_SCALE);
676
        }))
677
        .is_err();
678

            
679
        for size in [isize::MAX, isize::MIN, MAX_ENCODABLE_SIZE + 1, MIN_ENCODABLE_SIZE - 1] {
680
            let widget_panicked =
681
                catch_unwind(AssertUnwindSafe(|| drop(build_spinner_style(size, RED, GREEN))))
682
                    .is_err();
683

            
684
            assert_eq!(
685
                widget_panicked, profile_traps_overflow,
686
                "size {size}: the fixed-point encoding no longer behaves like a raw \
687
                 multiply (expected panic == {profile_traps_overflow})",
688
            );
689
        }
690
    }
691

            
692
    // ==================================================================
693
    // build_spinner_style — colour placement
694
    // ==================================================================
695

            
696
    #[test]
697
    fn only_the_top_side_gets_the_accent_colour() {
698
        // The whole spinner illusion is "one lit side, three faint ones". Swapping
699
        // a side would render a static ring with no visible arc.
700
        for (color, track) in [
701
            (RED, GREEN),
702
            (GHOST, RED),
703
            (RED, GHOST),
704
            (RED, RED),
705
            (ColorU { r: 0, g: 0, b: 0, a: 0 }, ColorU { r: 255, g: 255, b: 255, a: 255 }),
706
        ] {
707
            let style = build_spinner_style(24, color, track);
708
            let [top, right, bottom, left] = border_colors(&style);
709

            
710
            assert_eq!(top, color, "the top side lost the accent colour");
711
            assert_eq!([right, bottom, left], [track; 3], "a track side lost its colour");
712
        }
713
    }
714

            
715
    #[test]
716
    fn colour_channels_survive_untouched() {
717
        // Alpha in particular: a fully transparent accent must stay transparent
718
        // rather than being normalised to opaque somewhere in the pipeline.
719
        for a in [0_u8, 1, 127, 255] {
720
            let color = ColorU { r: 1, g: 2, b: 3, a };
721
            let track = ColorU { r: 253, g: 254, b: 255, a: 255 - a };
722
            let style = build_spinner_style(24, color, track);
723
            let [top, right, bottom, left] = border_colors(&style);
724

            
725
            assert_eq!((top.r, top.g, top.b, top.a), (1, 2, 3, a));
726
            for side in [right, bottom, left] {
727
                assert_eq!((side.r, side.g, side.b, side.a), (253, 254, 255, 255 - a));
728
            }
729
        }
730
    }
731

            
732
    #[test]
733
    fn all_four_sides_are_solid() {
734
        // `BorderStyle::None` on any side would delete that quarter of the ring.
735
        for size in SAFE_SIZES {
736
            assert_eq!(
737
                border_styles(&build_spinner_style(size, RED, GREEN)),
738
                [BorderStyle::Solid; 4],
739
                "a side stopped being solid at size {size}",
740
            );
741
        }
742
    }
743

            
744
    #[test]
745
    fn the_ring_neither_grows_nor_stretches() {
746
        // `align-self: start` + `flex-grow: 0` are what stop a flex parent from
747
        // stretching the ring into an ellipse.
748
        let style = build_spinner_style(24, RED, GREEN);
749

            
750
        let align = find(&style, |p| match p {
751
            CssProperty::AlignSelf(x) => x.get_property().copied(),
752
            _ => None,
753
        });
754
        let grow = find(&style, |p| match p {
755
            CssProperty::FlexGrow(x) => x.get_property().map(|x| x.inner),
756
            _ => None,
757
        });
758

            
759
        assert_eq!(align, Some(LayoutAlignSelf::Start));
760
        assert_eq!(grow.map(|g| g.number()), Some(0));
761
    }
762

            
763
    // ==================================================================
764
    // create / with_size / Default — construction invariants
765
    // ==================================================================
766

            
767
    #[test]
768
    fn create_matches_the_documented_defaults() {
769
        let s = Spinner::create();
770

            
771
        assert_eq!(s.size, DEFAULT_SIZE);
772
        assert_eq!(s.size, 24, "the documented default diameter changed");
773
        assert_eq!(s.color, ColorU { r: 13, g: 110, b: 253, a: 255 });
774
        assert_eq!(s.track_color, ColorU { r: 208, g: 212, b: 217, a: 255 });
775
        assert_eq!(
776
            s.spinner_style,
777
            build_spinner_style(DEFAULT_SIZE, DEFAULT_ACCENT_COLOR, DEFAULT_TRACK_COLOR),
778
        );
779
        // 24px → 3px border, 12px radius.
780
        assert_eq!(raw(border_widths(&s.spinner_style)[0]), 3 * FP_SCALE);
781
        assert_eq!(raw(radii(&s.spinner_style)[0]), 12 * FP_SCALE);
782
    }
783

            
784
    #[test]
785
    fn default_is_create() {
786
        assert_eq!(Spinner::default(), Spinner::create());
787
    }
788

            
789
    #[test]
790
    fn with_size_records_the_size_and_rebuilds_the_style() {
791
        for size in SAFE_SIZES {
792
            let s = Spinner::with_size(size);
793

            
794
            assert_eq!(s.size, size, "the size field does not match the argument");
795
            assert_eq!(s.color, DEFAULT_ACCENT_COLOR);
796
            assert_eq!(s.track_color, DEFAULT_TRACK_COLOR);
797
            assert_eq!(s.spinner_style.len(), DECLARATIONS);
798
            assert_eq!(
799
                s.spinner_style,
800
                build_spinner_style(size, DEFAULT_ACCENT_COLOR, DEFAULT_TRACK_COLOR),
801
            );
802
            assert_eq!(raw(width(&s.spinner_style)) / FP_SCALE, size);
803
        }
804
    }
805

            
806
    // ==================================================================
807
    // set_size / set_color / set_track_color — no cross-clobbering
808
    // ==================================================================
809

            
810
    #[test]
811
    fn set_size_keeps_the_custom_colours() {
812
        // `set_size` rebuilds the whole style, so it has to feed the *current*
813
        // colours back in — a regression here silently resets the palette.
814
        let mut s = Spinner::create().with_color(RED).with_track_color(GREEN);
815
        s.set_size(64);
816

            
817
        assert_eq!(s.size, 64);
818
        assert_eq!(s.color, RED);
819
        assert_eq!(s.track_color, GREEN);
820
        assert_eq!(border_colors(&s.spinner_style), [RED, GREEN, GREEN, GREEN]);
821
        assert_eq!(raw(width(&s.spinner_style)), 64 * FP_SCALE);
822
    }
823

            
824
    #[test]
825
    fn set_color_touches_only_the_accent() {
826
        let mut s = Spinner::with_size(48).with_track_color(GREEN);
827
        s.set_color(RED);
828

            
829
        assert_eq!(s.size, 48, "set_color moved the diameter");
830
        assert_eq!(s.track_color, GREEN, "set_color clobbered the track colour");
831
        assert_eq!(border_colors(&s.spinner_style), [RED, GREEN, GREEN, GREEN]);
832
        assert_eq!(raw(width(&s.spinner_style)), 48 * FP_SCALE);
833
    }
834

            
835
    #[test]
836
    fn set_track_color_touches_only_the_track() {
837
        let mut s = Spinner::with_size(48).with_color(RED);
838
        s.set_track_color(GREEN);
839

            
840
        assert_eq!(s.size, 48, "set_track_color moved the diameter");
841
        assert_eq!(s.color, RED, "set_track_color clobbered the accent colour");
842
        assert_eq!(border_colors(&s.spinner_style), [RED, GREEN, GREEN, GREEN]);
843
    }
844

            
845
    #[test]
846
    fn setters_are_idempotent_and_never_grow_the_style() {
847
        // The style is *replaced*, not appended to: a hundred rounds of setters
848
        // must leave exactly the same 20 declarations as one round.
849
        let mut s = Spinner::create();
850
        for _ in 0..100 {
851
            s.set_size(24);
852
            s.set_color(RED);
853
            s.set_track_color(GREEN);
854
        }
855

            
856
        let once = Spinner::with_size(24).with_color(RED).with_track_color(GREEN);
857
        assert_eq!(s.spinner_style.len(), DECLARATIONS, "the style vec grew");
858
        assert_eq!(s, once, "repeated setters diverged from a single application");
859
    }
860

            
861
    #[test]
862
    fn set_size_survives_every_encodable_diameter() {
863
        // The same spinner walked across the whole safe range: each step must
864
        // leave a fully-formed style, with no state left over from the previous.
865
        let mut s = Spinner::create().with_color(RED).with_track_color(GREEN);
866
        for size in SAFE_SIZES {
867
            s.set_size(size);
868

            
869
            assert_eq!(s.size, size);
870
            assert_eq!(s.spinner_style.len(), DECLARATIONS);
871
            assert_eq!(s, Spinner::with_size(size).with_color(RED).with_track_color(GREEN));
872
        }
873
    }
874

            
875
    // ==================================================================
876
    // Builder setters mirror the mutating ones
877
    // ==================================================================
878

            
879
    #[test]
880
    fn builder_setters_match_the_mutating_setters() {
881
        for size in SAFE_SIZES {
882
            let mut mutated = Spinner::create();
883
            mutated.set_size(size);
884
            assert_eq!(Spinner::create().with_spinner_size(size), mutated, "size {size}");
885
        }
886

            
887
        let mut mutated = Spinner::create();
888
        mutated.set_color(RED);
889
        assert_eq!(Spinner::create().with_color(RED), mutated);
890

            
891
        let mut mutated = Spinner::create();
892
        mutated.set_track_color(GHOST);
893
        assert_eq!(Spinner::create().with_track_color(GHOST), mutated);
894
    }
895

            
896
    #[test]
897
    fn the_builder_chain_is_order_independent() {
898
        // Each setter rebuilds from all three fields, so the final spinner must
899
        // not depend on the order the fields were set in.
900
        let a = Spinner::create()
901
            .with_spinner_size(40)
902
            .with_color(RED)
903
            .with_track_color(GREEN);
904
        let b = Spinner::create()
905
            .with_track_color(GREEN)
906
            .with_color(RED)
907
            .with_spinner_size(40);
908
        let c = Spinner::create()
909
            .with_color(RED)
910
            .with_spinner_size(40)
911
            .with_track_color(GREEN);
912

            
913
        assert_eq!(a, b, "setting the size last changed the result");
914
        assert_eq!(a, c, "interleaving the setters changed the result");
915
    }
916

            
917
    #[test]
918
    fn equality_distinguishes_every_field() {
919
        let base = Spinner::create();
920

            
921
        assert_ne!(base, Spinner::create().with_spinner_size(25));
922
        assert_ne!(base, Spinner::create().with_color(RED));
923
        assert_ne!(base, Spinner::create().with_track_color(RED));
924
        assert_eq!(base, Spinner::create().with_spinner_size(DEFAULT_SIZE));
925
    }
926

            
927
    // ==================================================================
928
    // swap_with_default
929
    // ==================================================================
930

            
931
    #[test]
932
    fn swap_with_default_returns_the_original_and_installs_a_default() {
933
        let mut s = Spinner::with_size(96).with_color(RED).with_track_color(GREEN);
934
        let expected = s.clone();
935

            
936
        let taken = s.swap_with_default();
937

            
938
        assert_eq!(taken, expected, "the returned spinner is not the original");
939
        assert_eq!(s, Spinner::create(), "the receiver is not a fresh default");
940
        // The returned value must own a live style, not a moved-out husk.
941
        assert_eq!(taken.spinner_style.len(), DECLARATIONS);
942
        assert_eq!(border_colors(&taken.spinner_style), [RED, GREEN, GREEN, GREEN]);
943
    }
944

            
945
    #[test]
946
    fn swapping_twice_leaves_a_default_both_times() {
947
        let mut s = Spinner::with_size(MAX_ENCODABLE_SIZE);
948

            
949
        let first = s.swap_with_default();
950
        let second = s.swap_with_default();
951

            
952
        assert_eq!(first.size, MAX_ENCODABLE_SIZE);
953
        assert_eq!(second, Spinner::create(), "the second swap did not return the default");
954
        assert_eq!(s, Spinner::create());
955
    }
956

            
957
    #[test]
958
    fn swap_on_a_default_is_observationally_a_no_op() {
959
        let mut s = Spinner::create();
960
        let taken = s.swap_with_default();
961

            
962
        assert_eq!(taken, Spinner::create());
963
        assert_eq!(s, Spinner::create());
964
    }
965

            
966
    // ==================================================================
967
    // Clone / ownership — the style vec is heap memory behind a C ABI
968
    // ==================================================================
969

            
970
    #[test]
971
    fn clone_deep_copies_the_style_buffer() {
972
        // `CssPropertyWithConditionsVec` is a raw-pointer FFI vec: a shallow clone
973
        // would alias one allocation into two owners and double-free it.
974
        let original = Spinner::with_size(32).with_color(RED);
975
        let copy = original.clone();
976

            
977
        assert_eq!(copy, original);
978
        assert_ne!(
979
            original.spinner_style.as_ptr(),
980
            copy.spinner_style.as_ptr(),
981
            "the clone shares the original's style buffer",
982
        );
983
    }
984

            
985
    #[test]
986
    fn mutating_a_clone_leaves_the_original_alone() {
987
        let original = Spinner::with_size(32).with_color(RED);
988
        let mut copy = original.clone();
989

            
990
        copy.set_size(8);
991
        copy.set_track_color(GHOST);
992

            
993
        assert_eq!(original.size, 32, "mutating the clone moved the original's size");
994
        assert_eq!(original.track_color, DEFAULT_TRACK_COLOR);
995
        assert_eq!(raw(width(&original.spinner_style)), 32 * FP_SCALE);
996
        assert_eq!(border_colors(&original.spinner_style)[0], RED);
997
    }
998

            
999
    // ==================================================================
    // dom()
    // ==================================================================
    #[test]
    fn dom_is_a_single_classed_div() {
        let dom = Spinner::create().dom();
        assert_eq!(*dom.root.get_node_type(), NodeType::Div);
        assert!(dom.children.as_ref().is_empty(), "the spinner must be a leaf node");
        assert_eq!(dom.estimated_total_children, 0);
        assert_eq!(dom_classes(&dom), vec!["__azul-native-spinner".to_string()]);
    }
    #[test]
    fn dom_carries_exactly_the_spinner_style() {
        // `with_css_props` turns the vec into one inline rule per declaration;
        // nothing may be dropped, reordered or made conditional on the way.
        for size in SAFE_SIZES {
            let s = Spinner::with_size(size).with_color(RED).with_track_color(GREEN);
            let expected = props(&s.spinner_style);
            let dom = s.dom();
            assert_eq!(dom_props(&dom), expected, "the DOM lost declarations for size {size}");
            assert!(
                dom.root.style.iter_inline_properties().all(|(_, c)| c.is_empty()),
                "size {size}: an inline declaration became conditional",
            );
        }
    }
    #[test]
    fn from_impl_matches_the_dom_method() {
        let s = Spinner::with_size(17).with_color(GHOST);
        assert_eq!(Dom::from(s.clone()), s.dom());
    }
    #[test]
    fn dom_is_deterministic_for_equal_inputs() {
        let a = Spinner::with_size(13).with_color(RED).with_track_color(GHOST).dom();
        let b = Spinner::with_size(13).with_color(RED).with_track_color(GHOST).dom();
        assert_eq!(a, b, "two identically-built spinners rendered differently");
    }
    #[test]
    fn dom_survives_every_encodable_size_and_colour() {
        for size in SAFE_SIZES {
            for (color, track) in [(RED, GREEN), (GHOST, GHOST)] {
                let dom = Spinner::with_size(size)
                    .with_color(color)
                    .with_track_color(track)
                    .dom();
                assert_eq!(dom_props(&dom).len(), DECLARATIONS, "shape changed for {size}");
                assert!(dom.children.as_ref().is_empty());
                assert_eq!(dom_classes(&dom).len(), 1);
            }
        }
    }
}