Skip to main content

azul_layout/widgets/
spinner.rs

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
25use azul_core::dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec};
26use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
27use 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
37static SPINNER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-spinner"))];
38
39/// Default ring diameter, in logical px.
40const DEFAULT_SIZE: isize = 24;
41/// Faint "track" colour for the three inactive sides (#d0d4d9).
42const 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).
44const 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)]
51pub 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`).
65fn 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    let border_width = (size / 8).max(2);
68    let radius = size / 2;
69    CssPropertyWithConditionsVec::from_vec(alloc::vec![
70        // Hug its own size inside a flex parent rather than stretch/grow.
71        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
72        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
73            0,
74        ))),
75        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(size))),
76        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(size))),
77        // border: <border_width>px solid — three sides track, top accent.
78        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
79            LayoutBorderTopWidth::const_px(border_width),
80        )),
81        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
82            LayoutBorderBottomWidth::const_px(border_width),
83        )),
84        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
85            LayoutBorderLeftWidth::const_px(border_width),
86        )),
87        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
88            LayoutBorderRightWidth::const_px(border_width),
89        )),
90        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
91            inner: BorderStyle::Solid,
92        })),
93        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
94            StyleBorderBottomStyle {
95                inner: BorderStyle::Solid,
96            },
97        )),
98        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
99            inner: BorderStyle::Solid,
100        })),
101        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
102            StyleBorderRightStyle {
103                inner: BorderStyle::Solid,
104            },
105        )),
106        // top = accent (the visible "arc"); other three = faint track.
107        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
108            inner: color,
109        })),
110        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
111            StyleBorderBottomColor { inner: track_color },
112        )),
113        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
114            inner: track_color,
115        })),
116        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
117            StyleBorderRightColor { inner: track_color },
118        )),
119        // border-radius: size/2 → a circle.
120        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
121            StyleBorderTopLeftRadius::const_px(radius),
122        )),
123        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
124            StyleBorderTopRightRadius::const_px(radius),
125        )),
126        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
127            StyleBorderBottomLeftRadius::const_px(radius),
128        )),
129        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
130            StyleBorderBottomRightRadius::const_px(radius),
131        )),
132    ])
133}
134
135impl Spinner {
136    /// Creates a new spinner with the default size (24px) and accent colour.
137    #[inline]
138    #[must_use] pub fn create() -> Self {
139        Self::with_size(DEFAULT_SIZE)
140    }
141
142    /// Creates a new spinner with the given diameter (logical px) and the
143    /// default colours.
144    #[inline]
145    #[must_use] pub fn with_size(size: isize) -> Self {
146        Self {
147            size,
148            color: DEFAULT_ACCENT_COLOR,
149            track_color: DEFAULT_TRACK_COLOR,
150            spinner_style: build_spinner_style(size, DEFAULT_ACCENT_COLOR, DEFAULT_TRACK_COLOR),
151        }
152    }
153
154    /// Sets the ring diameter (logical px), recomputing the style.
155    #[inline]
156    pub fn set_size(&mut self, size: isize) {
157        self.size = size;
158        self.spinner_style = build_spinner_style(size, self.color, self.track_color);
159    }
160
161    /// Builder-style setter for the ring diameter.
162    #[inline]
163    #[must_use] pub fn with_spinner_size(mut self, size: isize) -> Self {
164        self.set_size(size);
165        self
166    }
167
168    /// Sets the active-arc colour, recomputing the style.
169    #[inline]
170    pub fn set_color(&mut self, color: ColorU) {
171        self.color = color;
172        self.spinner_style = build_spinner_style(self.size, color, self.track_color);
173    }
174
175    /// Builder-style setter for the active-arc colour.
176    #[inline]
177    #[must_use] pub fn with_color(mut self, color: ColorU) -> Self {
178        self.set_color(color);
179        self
180    }
181
182    /// Sets the inactive "track" colour, recomputing the style.
183    #[inline]
184    pub fn set_track_color(&mut self, track_color: ColorU) {
185        self.track_color = track_color;
186        self.spinner_style = build_spinner_style(self.size, self.color, track_color);
187    }
188
189    /// Builder-style setter for the inactive "track" colour.
190    #[inline]
191    #[must_use] pub fn with_track_color(mut self, track_color: ColorU) -> Self {
192        self.set_track_color(track_color);
193        self
194    }
195
196    /// Replaces `self` with a default spinner and returns the original.
197    #[inline]
198    #[must_use] pub fn swap_with_default(&mut self) -> Self {
199        let mut s = Self::create();
200        core::mem::swap(&mut s, self);
201        s
202    }
203
204    /// Converts this spinner into a single DOM node with the
205    /// `__azul-native-spinner` class.
206    #[inline]
207    #[must_use] pub fn dom(self) -> Dom {
208        Dom::create_div()
209            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
210            .with_css_props(self.spinner_style)
211    }
212}
213
214impl Default for Spinner {
215    fn default() -> Self {
216        Self::create()
217    }
218}
219
220impl From<Spinner> for Dom {
221    fn from(s: Spinner) -> Self {
222        s.dom()
223    }
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)]
234mod 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                IdOrClass::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    // ==================================================================
1000    // dom()
1001    // ==================================================================
1002
1003    #[test]
1004    fn dom_is_a_single_classed_div() {
1005        let dom = Spinner::create().dom();
1006
1007        assert_eq!(*dom.root.get_node_type(), NodeType::Div);
1008        assert!(dom.children.as_ref().is_empty(), "the spinner must be a leaf node");
1009        assert_eq!(dom.estimated_total_children, 0);
1010        assert_eq!(dom_classes(&dom), vec!["__azul-native-spinner".to_string()]);
1011    }
1012
1013    #[test]
1014    fn dom_carries_exactly_the_spinner_style() {
1015        // `with_css_props` turns the vec into one inline rule per declaration;
1016        // nothing may be dropped, reordered or made conditional on the way.
1017        for size in SAFE_SIZES {
1018            let s = Spinner::with_size(size).with_color(RED).with_track_color(GREEN);
1019            let expected = props(&s.spinner_style);
1020            let dom = s.dom();
1021
1022            assert_eq!(dom_props(&dom), expected, "the DOM lost declarations for size {size}");
1023            assert!(
1024                dom.root.style.iter_inline_properties().all(|(_, c)| c.is_empty()),
1025                "size {size}: an inline declaration became conditional",
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn from_impl_matches_the_dom_method() {
1032        let s = Spinner::with_size(17).with_color(GHOST);
1033        assert_eq!(Dom::from(s.clone()), s.dom());
1034    }
1035
1036    #[test]
1037    fn dom_is_deterministic_for_equal_inputs() {
1038        let a = Spinner::with_size(13).with_color(RED).with_track_color(GHOST).dom();
1039        let b = Spinner::with_size(13).with_color(RED).with_track_color(GHOST).dom();
1040
1041        assert_eq!(a, b, "two identically-built spinners rendered differently");
1042    }
1043
1044    #[test]
1045    fn dom_survives_every_encodable_size_and_colour() {
1046        for size in SAFE_SIZES {
1047            for (color, track) in [(RED, GREEN), (GHOST, GHOST)] {
1048                let dom = Spinner::with_size(size)
1049                    .with_color(color)
1050                    .with_track_color(track)
1051                    .dom();
1052
1053                assert_eq!(dom_props(&dom).len(), DECLARATIONS, "shape changed for {size}");
1054                assert!(dom.children.as_ref().is_empty());
1055                assert_eq!(dom_classes(&dom).len(), 1);
1056            }
1057        }
1058    }
1059}