1
//! Radio-group widget — a vertical (or horizontal) group of mutually-exclusive
2
//! options where exactly one is selected. Combines the sibling-navigation +
3
//! `selected_index` state of [`crate::widgets::segmented::Segmented`] with the
4
//! circular filled/empty indicator visual of
5
//! [`crate::widgets::check_box::CheckBox`].
6
//!
7
//! Each option is a row: a circular indicator (an outer ring containing an inner
8
//! dot whose opacity is `100` when selected, `0` otherwise) followed by a text
9
//! label. Clicking any row selects it: the internal handler computes the clicked
10
//! row's index from its position among its siblings, updates `selected_index`,
11
//! invokes the user's `on_change(index)`, and live-restyles every row's dot via
12
//! `set_css_property`.
13
//!
14
//! Key types: [`RadioGroup`], [`RadioGroupState`], [`RadioGroupOnChange`].
15

            
16
use std::vec::Vec;
17

            
18
use azul_core::{
19
    callbacks::{CoreCallbackData, Update},
20
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
21
    refany::RefAny,
22
};
23
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
24
use azul_css::{
25
    props::{
26
        basic::{color::ColorU, StyleFontSize},
27
        layout::{LayoutDisplay, LayoutFlexDirection, LayoutJustifyContent, LayoutAlignItems, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutAlignSelf, LayoutMarginRight, LayoutMarginBottom, LayoutMarginLeft},
28
        property::{CssProperty, *},
29
        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleOpacity, StyleCursor, StyleUserSelect},
30
    },
31
    impl_option_inner, AzString, StringVec,
32
};
33

            
34
use crate::callbacks::{Callback, CallbackInfo};
35

            
36
static RADIO_GROUP_CLASS: &[IdOrClass] =
37
    &[Class(AzString::from_const_str("__azul-native-radio-group"))];
38
static RADIO_GROUP_ROW_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
39
    "__azul-native-radio-group-row",
40
))];
41
static RADIO_GROUP_CIRCLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
42
    "__azul-native-radio-group-circle",
43
))];
44
static RADIO_GROUP_DOT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
45
    "__azul-native-radio-group-dot",
46
))];
47
static RADIO_GROUP_LABEL_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
48
    "__azul-native-radio-group-label",
49
))];
50

            
51
/// Callback function type invoked when the selected option changes.
52
pub type RadioGroupOnChangeCallbackType =
53
    extern "C" fn(RefAny, CallbackInfo, RadioGroupState) -> Update;
54
impl_widget_callback!(
55
    RadioGroupOnChange,
56
    OptionRadioGroupOnChange,
57
    RadioGroupOnChangeCallback,
58
    RadioGroupOnChangeCallbackType
59
);
60

            
61
azul_core::impl_managed_callback! {
62
    wrapper:        RadioGroupOnChangeCallback,
63
    info_ty:        CallbackInfo,
64
    return_ty:      Update,
65
    default_ret:    Update::DoNothing,
66
    invoker_static: RADIO_GROUP_ON_CHANGE_INVOKER,
67
    invoker_ty:     AzRadioGroupOnChangeCallbackInvoker,
68
    thunk_fn:       az_radio_group_on_change_callback_thunk,
69
    setter_fn:      AzApp_setRadioGroupOnChangeCallbackInvoker,
70
    from_handle_fn: AzRadioGroupOnChangeCallback_createFromHostHandle,
71
    extra_args:     [ state: RadioGroupState ],
72
}
73

            
74
/// A group of mutually-exclusive radio options with a selection callback.
75
#[derive(Debug, Clone, PartialEq, Eq)]
76
#[repr(C)]
77
pub struct RadioGroup {
78
    pub radio_group_state: RadioGroupStateWrapper,
79
    /// The label of each option, in order.
80
    pub options: StringVec,
81
    /// Style for the group container.
82
    pub container_style: CssPropertyWithConditionsVec,
83
}
84

            
85
#[derive(Debug, Default, Clone, PartialEq, Eq)]
86
#[repr(C)]
87
pub struct RadioGroupStateWrapper {
88
    /// The current selection.
89
    pub inner: RadioGroupState,
90
    /// `true` lays the options out in a horizontal row, `false` (default) stacks
91
    /// them vertically.
92
    pub horizontal: bool,
93
    /// Optional: function to call when the selection changes.
94
    pub on_change: OptionRadioGroupOnChange,
95
}
96

            
97
/// State of a [`RadioGroup`]: the index of the currently selected option.
98
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
99
#[repr(C)]
100
pub struct RadioGroupState {
101
    /// Zero-based index of the selected option.
102
    pub selected_index: usize,
103
}
104

            
105
// ---- dimensions (logical px) ----
106
const CIRCLE_SIZE: isize = 16;
107
const CIRCLE_RADIUS: isize = 8;
108
const CIRCLE_BORDER: isize = 1;
109
const DOT_SIZE: isize = 8;
110
const DOT_RADIUS: isize = 4;
111
/// Gap between stacked rows (vertical) / between side-by-side rows (horizontal).
112
const ROW_GAP: isize = 6;
113
/// Gap between the indicator circle and its label.
114
const LABEL_GAP: isize = 8;
115

            
116
// ---- colours ----
117
/// Indicator ring colour (#9b9b9b).
118
const CIRCLE_BORDER_COLOR: ColorU = ColorU {
119
    r: 155,
120
    g: 155,
121
    b: 155,
122
    a: 255,
123
};
124
/// Selected dot fill (#0d6efd, accent blue).
125
const DOT_COLOR: ColorU = ColorU {
126
    r: 13,
127
    g: 110,
128
    b: 253,
129
    a: 255,
130
};
131

            
132
const DOT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(DOT_COLOR)];
133
const DOT_BG: StyleBackgroundContentVec = StyleBackgroundContentVec::from_const_slice(DOT_BG_ITEMS);
134

            
135
/// Outer ring of one option's indicator (parameter-independent → const slice).
136
/// A flex box that centres its inner dot.
137
static RADIO_GROUP_CIRCLE_STYLE: &[CssPropertyWithConditions] = &[
138
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
139
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
140
    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
141
        LayoutJustifyContent::Center,
142
    )),
143
    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
144
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
145
    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(CIRCLE_SIZE))),
146
    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(CIRCLE_SIZE))),
147
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
148
        LayoutBorderTopWidth::const_px(CIRCLE_BORDER),
149
    )),
150
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
151
        LayoutBorderBottomWidth::const_px(CIRCLE_BORDER),
152
    )),
153
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
154
        LayoutBorderLeftWidth::const_px(CIRCLE_BORDER),
155
    )),
156
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
157
        LayoutBorderRightWidth::const_px(CIRCLE_BORDER),
158
    )),
159
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
160
        inner: BorderStyle::Solid,
161
    })),
162
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
163
        StyleBorderBottomStyle {
164
            inner: BorderStyle::Solid,
165
        },
166
    )),
167
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
168
        inner: BorderStyle::Solid,
169
    })),
170
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
171
        StyleBorderRightStyle {
172
            inner: BorderStyle::Solid,
173
        },
174
    )),
175
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
176
        inner: CIRCLE_BORDER_COLOR,
177
    })),
178
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
179
        StyleBorderBottomColor {
180
            inner: CIRCLE_BORDER_COLOR,
181
        },
182
    )),
183
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
184
        inner: CIRCLE_BORDER_COLOR,
185
    })),
186
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
187
        StyleBorderRightColor {
188
            inner: CIRCLE_BORDER_COLOR,
189
        },
190
    )),
191
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
192
        StyleBorderTopLeftRadius::const_px(CIRCLE_RADIUS),
193
    )),
194
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
195
        StyleBorderTopRightRadius::const_px(CIRCLE_RADIUS),
196
    )),
197
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
198
        StyleBorderBottomLeftRadius::const_px(CIRCLE_RADIUS),
199
    )),
200
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
201
        StyleBorderBottomRightRadius::const_px(CIRCLE_RADIUS),
202
    )),
203
];
204

            
205
/// Inner filled dot when the option is SELECTED (opacity 100).
206
static RADIO_GROUP_DOT_STYLE_SELECTED: &[CssPropertyWithConditions] = &[
207
    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(DOT_SIZE))),
208
    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(DOT_SIZE))),
209
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
210
    CssPropertyWithConditions::simple(CssProperty::const_background_content(DOT_BG)),
211
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
212
        StyleBorderTopLeftRadius::const_px(DOT_RADIUS),
213
    )),
214
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
215
        StyleBorderTopRightRadius::const_px(DOT_RADIUS),
216
    )),
217
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
218
        StyleBorderBottomLeftRadius::const_px(DOT_RADIUS),
219
    )),
220
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
221
        StyleBorderBottomRightRadius::const_px(DOT_RADIUS),
222
    )),
223
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
224
];
225

            
226
/// Inner filled dot when the option is UNSELECTED (opacity 0 — hidden but laid out).
227
static RADIO_GROUP_DOT_STYLE_UNSELECTED: &[CssPropertyWithConditions] = &[
228
    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(DOT_SIZE))),
229
    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(DOT_SIZE))),
230
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
231
    CssPropertyWithConditions::simple(CssProperty::const_background_content(DOT_BG)),
232
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
233
        StyleBorderTopLeftRadius::const_px(DOT_RADIUS),
234
    )),
235
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
236
        StyleBorderTopRightRadius::const_px(DOT_RADIUS),
237
    )),
238
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
239
        StyleBorderBottomLeftRadius::const_px(DOT_RADIUS),
240
    )),
241
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
242
        StyleBorderBottomRightRadius::const_px(DOT_RADIUS),
243
    )),
244
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
245
];
246

            
247
/// Builds the container style. Orientation (row vs column) is the only
248
/// parameter-dependent property, so the style is built at runtime.
249
290
fn build_container_style(horizontal: bool) -> CssPropertyWithConditionsVec {
250
290
    let direction = if horizontal {
251
62
        LayoutFlexDirection::Row
252
    } else {
253
228
        LayoutFlexDirection::Column
254
    };
255
290
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
256
290
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
257
290
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(direction)),
258
290
        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
259
290
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
260
            0,
261
        ))),
262
    ])
263
290
}
264

            
265
/// Builds one option's row style. The orientation decides whether the inter-row
266
/// gap is applied to the bottom (vertical) or the right (horizontal).
267
135
fn build_row_style(horizontal: bool) -> CssPropertyWithConditionsVec {
268
135
    let mut v: Vec<CssPropertyWithConditions> = alloc::vec![
269
135
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
270
135
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
271
135
            LayoutFlexDirection::Row,
272
        )),
273
135
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
274
135
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
275
            0,
276
        ))),
277
135
        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
278
135
        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
279
    ];
280
135
    if horizontal {
281
10
        v.push(CssPropertyWithConditions::simple(
282
10
            CssProperty::const_margin_right(LayoutMarginRight::const_px(ROW_GAP * 2)),
283
10
        ));
284
125
    } else {
285
125
        v.push(CssPropertyWithConditions::simple(
286
125
            CssProperty::const_margin_bottom(LayoutMarginBottom::const_px(ROW_GAP)),
287
125
        ));
288
125
    }
289
135
    CssPropertyWithConditionsVec::from_vec(v)
290
135
}
291

            
292
/// The label-text style: a small left gap from the indicator.
293
static RADIO_GROUP_LABEL_STYLE: &[CssPropertyWithConditions] = &[
294
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
295
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
296
    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(
297
        LABEL_GAP,
298
    ))),
299
];
300

            
301
impl RadioGroup {
302
    /// Creates a radio group from the given options, with the first one selected.
303
163
    #[must_use] pub fn create(options: StringVec) -> Self {
304
163
        Self {
305
163
            radio_group_state: RadioGroupStateWrapper {
306
163
                inner: RadioGroupState { selected_index: 0 },
307
163
                horizontal: false,
308
163
                ..Default::default()
309
163
            },
310
163
            options,
311
163
            container_style: build_container_style(false),
312
163
        }
313
163
    }
314

            
315
    /// Sets the currently selected option index.
316
    #[inline]
317
41
    pub const fn set_selected_index(&mut self, selected_index: usize) {
318
41
        self.radio_group_state.inner.selected_index = selected_index;
319
41
    }
320

            
321
    /// Builder-style setter for the selected option index.
322
    #[inline]
323
29
    #[must_use] pub const fn with_selected_index(mut self, selected_index: usize) -> Self {
324
29
        self.set_selected_index(selected_index);
325
29
        self
326
29
    }
327

            
328
    /// Lays the options out horizontally (default is vertical).
329
    #[inline]
330
112
    pub fn set_horizontal(&mut self, horizontal: bool) {
331
112
        self.radio_group_state.horizontal = horizontal;
332
112
        self.container_style = build_container_style(horizontal);
333
112
    }
334

            
335
    /// Builder-style setter for the horizontal layout flag.
336
    #[inline]
337
9
    #[must_use] pub fn with_horizontal(mut self, horizontal: bool) -> Self {
338
9
        self.set_horizontal(horizontal);
339
9
        self
340
9
    }
341

            
342
    #[inline]
343
5
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
344
5
        let mut s = Self::create(StringVec::from_const_slice(&[]));
345
5
        core::mem::swap(&mut s, self);
346
5
        s
347
5
    }
348

            
349
    #[inline]
350
11
    pub fn set_on_change<C: Into<RadioGroupOnChangeCallback>>(
351
11
        &mut self,
352
11
        data: RefAny,
353
11
        on_change: C,
354
11
    ) {
355
11
        self.radio_group_state.on_change = Some(RadioGroupOnChange {
356
11
            callback: on_change.into(),
357
11
            refany: data,
358
11
        })
359
11
        .into();
360
11
    }
361

            
362
    #[inline]
363
8
    #[must_use] pub fn with_on_change<C: Into<RadioGroupOnChangeCallback>>(
364
8
        mut self,
365
8
        data: RefAny,
366
8
        on_change: C,
367
8
    ) -> Self {
368
8
        self.set_on_change(data, on_change);
369
8
        self
370
8
    }
371

            
372
119
    #[must_use] pub fn dom(self) -> Dom {
373
        use azul_core::{
374
            callbacks::CoreCallback,
375
            dom::{EventFilter, HoverEventFilter},
376
            refany::OptionRefAny,
377
        };
378

            
379
119
        let selected = self.radio_group_state.inner.selected_index;
380
119
        let horizontal = self.radio_group_state.horizontal;
381
119
        let count = self.options.as_ref().len();
382

            
383
119
        let row_style = build_row_style(horizontal);
384

            
385
        // One shared RefAny across every row's callback (RefAny::clone shares
386
        // the underlying state — same pattern as segmented/tabs/map).
387
119
        let state = RefAny::new(self.radio_group_state);
388

            
389
119
        let mut children: Vec<Dom> = Vec::with_capacity(count);
390
1101
        for (i, label) in self.options.as_ref().iter().enumerate() {
391
1101
            let dot_style = if i == selected {
392
108
                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_SELECTED)
393
            } else {
394
993
                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_UNSELECTED)
395
            };
396

            
397
1101
            let circle = Dom::create_div()
398
1101
                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CIRCLE_CLASS))
399
1101
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
400
1101
                    RADIO_GROUP_CIRCLE_STYLE,
401
                ))
402
1101
                .with_children(
403
1101
                    vec![Dom::create_div()
404
1101
                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
405
1101
                            RADIO_GROUP_DOT_CLASS,
406
                        ))
407
1101
                        .with_css_props(dot_style)]
408
1101
                    .into(),
409
                );
410

            
411
1101
            let label_node = Dom::create_p_with_text(label.clone())
412
1101
                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_LABEL_CLASS))
413
1101
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
414
1101
                    RADIO_GROUP_LABEL_STYLE,
415
                ));
416

            
417
1101
            children.push(
418
1101
                Dom::create_div()
419
1101
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_ROW_CLASS))
420
1101
                    .with_css_props(row_style.clone())
421
1101
                    .with_callbacks(
422
1101
                        vec![CoreCallbackData {
423
1101
                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
424
1101
                            callback: CoreCallback {
425
1101
                                cb: on_radio_row_click as usize,
426
1101
                                ctx: OptionRefAny::None,
427
1101
                            },
428
1101
                            refany: state.clone(),
429
1101
                        }]
430
1101
                        .into(),
431
                    )
432
1101
                    .with_tab_index(TabIndex::Auto)
433
1101
                    .with_children(vec![circle, label_node].into()),
434
            );
435
        }
436

            
437
119
        Dom::create_div()
438
119
            .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CLASS))
439
119
            .with_css_props(self.container_style)
440
119
            .with_children(children.into())
441
119
    }
442
}
443

            
444
impl Default for RadioGroup {
445
10
    fn default() -> Self {
446
10
        Self::create(StringVec::from_const_slice(&[]))
447
10
    }
448
}
449

            
450
/// Click handler shared by all rows. Determines the clicked row's index from its
451
/// position among its siblings (the hit node resolves to the row the callback is
452
/// registered on — currentTarget semantics — regardless of whether the dot,
453
/// circle or label was clicked), updates the selection, invokes the user
454
/// callback, and live-restyles every row's indicator dot.
455
89
extern "C" fn on_radio_row_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
456
    use azul_core::dom::DomNodeId;
457

            
458
89
    let clicked = info.get_hit_node();
459
89
    let Some(parent) = info.get_parent(clicked) else {
460
5
        return Update::DoNothing;
461
    };
462

            
463
    // Collect the option rows in document order.
464
84
    let mut rows: Vec<DomNodeId> = Vec::new();
465
84
    let mut cur = info.get_first_child(parent);
466
591
    while let Some(node) = cur {
467
507
        rows.push(node);
468
507
        cur = info.get_next_sibling(node);
469
507
    }
470

            
471
301
    let Some(selected) = rows.iter().position(|n| *n == clicked) else {
472
        return Update::DoNothing;
473
    };
474

            
475
82
    let result = {
476
84
        let Some(mut rg) = data.downcast_mut::<RadioGroupStateWrapper>() else {
477
2
            return Update::DoNothing;
478
        };
479
82
        rg.inner.selected_index = selected;
480
82
        let inner = rg.inner;
481
82
        let rg = &mut *rg;
482
82
        match rg.on_change.as_mut() {
483
6
            Some(RadioGroupOnChange { callback, refany }) => {
484
6
                (callback.cb)(refany.clone(), info, inner)
485
            }
486
76
            None => Update::DoNothing,
487
        }
488
    };
489

            
490
    // Live-restyle every row's dot: the selected option's dot becomes visible
491
    // (opacity 100), the rest are hidden (opacity 0). Each row is
492
    // `row → circle (first child) → dot (first child)`.
493
503
    for (i, row) in rows.iter().enumerate() {
494
503
        let Some(circle) = info.get_first_child(*row) else {
495
2
            continue;
496
        };
497
501
        let Some(dot) = info.get_first_child(circle) else {
498
4
            continue;
499
        };
500
497
        let opacity = if i == selected { 100 } else { 0 };
501
497
        info.set_css_property(dot, CssProperty::const_opacity(StyleOpacity::const_new(opacity)));
502
    }
503

            
504
82
    result
505
89
}
506

            
507
impl From<RadioGroup> for Dom {
508
    fn from(r: RadioGroup) -> Self {
509
        r.dom()
510
    }
511
}
512

            
513
#[cfg(test)]
514
#[allow(clippy::float_cmp, clippy::too_many_lines)]
515
// `assertions_on_constants`: these are deliberate invariant guards over sibling
516
// `const`s in this module. They are const-foldable *today*, which is exactly the
517
// point — they must go red the moment someone edits one of those constants into an
518
// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
519
#[allow(clippy::assertions_on_constants)]
520
mod autotest_generated {
521
    use std::{
522
        collections::{BTreeMap, HashMap},
523
        mem::discriminant,
524
        sync::{Arc, Mutex},
525
    };
526

            
527
    use azul_core::{
528
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
529
        geom::{LogicalRect, OptionLogicalPosition},
530
        gl::OptionGlContextPtr,
531
        hit_test::ScrollPosition,
532
        refany::OptionRefAny,
533
        resources::RendererResources,
534
        styled_dom::{NodeHierarchyItemId, StyledDom},
535
        window::{MonitorVec, RawWindowHandle},
536
    };
537
    use azul_css::{
538
        props::basic::{length::SizeMetric, pixel::PixelValue},
539
        system::SystemStyle,
540
    };
541
    use rust_fontconfig::FcFontCache;
542

            
543
    use super::*;
544
    #[cfg(feature = "icu")]
545
    use crate::icu::IcuLocalizerHandle;
546
    use crate::{
547
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
548
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
549
        window::{DomLayoutResult, LayoutWindow},
550
        window_state::FullWindowState,
551
    };
552

            
553
    // ------------------------------------------------------------------
554
    // Fixtures
555
    // ------------------------------------------------------------------
556

            
557
    fn labels(v: &[&str]) -> StringVec {
558
        StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
559
    }
560

            
561
    /// `n` distinct labels: `o0, o1, … o{n-1}`.
562
    fn n_labels(n: usize) -> StringVec {
563
        StringVec::from_vec(
564
            (0..n)
565
                .map(|i| AzString::from(format!("o{i}")))
566
                .collect::<Vec<_>>(),
567
        )
568
    }
569

            
570
    fn group(v: &[&str]) -> RadioGroup {
571
        RadioGroup::create(labels(v))
572
    }
573

            
574
    // ------------------------------------------------------------------
575
    // Style probes
576
    // ------------------------------------------------------------------
577

            
578
    fn props(style: &[CssPropertyWithConditions]) -> Vec<CssProperty> {
579
        style.iter().map(|p| p.property.clone()).collect()
580
    }
581

            
582
    fn has_property(style: &[CssPropertyWithConditions], wanted: &CssProperty) -> bool {
583
        style.iter().any(|p| p.property == *wanted)
584
    }
585

            
586
    /// Every style in this file is unconditional — a stray `@media`/`:hover`
587
    /// condition would make the property silently not apply.
588
    fn all_unconditional(style: &[CssPropertyWithConditions]) -> bool {
589
        style.iter().all(|p| p.apply_if.as_ref().is_empty())
590
    }
591

            
592
    fn no_duplicate_properties(name: &str, style: &[CssPropertyWithConditions]) {
593
        let mut seen = Vec::new();
594
        for p in style {
595
            let d = discriminant(&p.property);
596
            assert!(
597
                !seen.contains(&d),
598
                "{name} declares {:?} twice — the later declaration silently wins",
599
                p.property,
600
            );
601
            seen.push(d);
602
        }
603
    }
604

            
605
    /// The opacity declared by a property list, normalized to `0.0..=1.0`.
606
    /// `StyleOpacity::const_new` takes a *percentage*, so `const_new(1)` would be
607
    /// 1% — a dot that is technically there but invisible.
608
    fn opacity_of(properties: &[CssProperty]) -> Option<f32> {
609
        properties.iter().find_map(|p| match p {
610
            CssProperty::Opacity(o) => o.get_property().map(|o| o.inner.normalized()),
611
            _ => None,
612
        })
613
    }
614

            
615
    fn flex_direction(properties: &[CssProperty]) -> Option<LayoutFlexDirection> {
616
        properties.iter().find_map(|p| match p {
617
            CssProperty::FlexDirection(d) => d.get_property().copied(),
618
            _ => None,
619
        })
620
    }
621

            
622
    fn cursor(properties: &[CssProperty]) -> Option<StyleCursor> {
623
        properties.iter().find_map(|p| match p {
624
            CssProperty::Cursor(c) => c.get_property().copied(),
625
            _ => None,
626
        })
627
    }
628

            
629
    fn user_select(properties: &[CssProperty]) -> Option<StyleUserSelect> {
630
        properties.iter().find_map(|p| match p {
631
            CssProperty::UserSelect(u) => u.get_property().copied(),
632
            _ => None,
633
        })
634
    }
635

            
636
    fn margin_bottom(properties: &[CssProperty]) -> Option<PixelValue> {
637
        properties.iter().find_map(|p| match p {
638
            CssProperty::MarginBottom(m) => m.get_property().map(|m| m.inner),
639
            _ => None,
640
        })
641
    }
642

            
643
    fn margin_right(properties: &[CssProperty]) -> Option<PixelValue> {
644
        properties.iter().find_map(|p| match p {
645
            CssProperty::MarginRight(m) => m.get_property().map(|m| m.inner),
646
            _ => None,
647
        })
648
    }
649

            
650
    fn margin_left(properties: &[CssProperty]) -> Option<PixelValue> {
651
        properties.iter().find_map(|p| match p {
652
            CssProperty::MarginLeft(m) => m.get_property().map(|m| m.inner),
653
            _ => None,
654
        })
655
    }
656

            
657
    fn width(properties: &[CssProperty]) -> Option<PixelValue> {
658
        properties.iter().find_map(|p| match p {
659
            CssProperty::Width(w) => match w.get_property() {
660
                Some(LayoutWidth::Px(pv)) => Some(*pv),
661
                _ => None,
662
            },
663
            _ => None,
664
        })
665
    }
666

            
667
    fn height(properties: &[CssProperty]) -> Option<PixelValue> {
668
        properties.iter().find_map(|p| match p {
669
            CssProperty::Height(h) => match h.get_property() {
670
                Some(LayoutHeight::Px(pv)) => Some(*pv),
671
                _ => None,
672
            },
673
            _ => None,
674
        })
675
    }
676

            
677
    fn border_top_left_radius(properties: &[CssProperty]) -> Option<PixelValue> {
678
        properties.iter().find_map(|p| match p {
679
            CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
680
            _ => None,
681
        })
682
    }
683

            
684
    /// Asserts the length is an absolute `px` and returns its magnitude. An `em`
685
    /// or `%` slipping into this widget's geometry would resolve against the
686
    /// parent font/box, so a 16px indicator could render at any size at all.
687
    fn px(pv: PixelValue) -> f32 {
688
        assert_eq!(
689
            pv.metric,
690
            SizeMetric::Px,
691
            "radio-group geometry must be absolute px, got {:?}",
692
            pv.metric,
693
        );
694
        pv.number.get()
695
    }
696

            
697
    // ------------------------------------------------------------------
698
    // Dom probes
699
    // ------------------------------------------------------------------
700

            
701
    fn classes(node: &Dom) -> Vec<String> {
702
        node.root
703
            .get_ids_and_classes()
704
            .as_ref()
705
            .iter()
706
            .filter_map(|c| match c {
707
                Class(s) => Some(s.as_str().to_string()),
708
                IdOrClass::Id(_) => None,
709
            })
710
            .collect()
711
    }
712

            
713
    /// The properties of a rendered node's *inline* style, in declaration order.
714
    fn inline_props(node: &Dom) -> Vec<CssProperty> {
715
        node.root
716
            .style
717
            .iter_inline_properties()
718
            .map(|(p, _)| p.clone())
719
            .collect()
720
    }
721

            
722
    /// The text of a text node, looking through the `<p>` block wrapper the
723
    /// label convention mandates (`p > text`).
724
    fn text_of(node: &Dom) -> Option<&str> {
725
        match node.root.get_node_type() {
726
            NodeType::Text(s) => Some(s.as_ref().as_str()),
727
            NodeType::P => match node.children.as_ref() {
728
                [only] => match only.root.get_node_type() {
729
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
730
                    _ => None,
731
                },
732
                _ => None,
733
            },
734
            _ => None,
735
        }
736
    }
737

            
738
    fn row_of(dom: &Dom, i: usize) -> &Dom {
739
        &dom.children.as_ref()[i]
740
    }
741

            
742
    /// `row → circle (first child) → dot (first child)` — the path the click
743
    /// handler itself walks.
744
    fn dot_of(dom: &Dom, i: usize) -> &Dom {
745
        &row_of(dom, i).children.as_ref()[0].children.as_ref()[0]
746
    }
747

            
748
    fn label_of(dom: &Dom, i: usize) -> &Dom {
749
        &row_of(dom, i).children.as_ref()[1]
750
    }
751

            
752
    /// The `RefAny` row `i`'s click callback carries.
753
    fn row_state(dom: &Dom, i: usize) -> RefAny {
754
        row_of(dom, i)
755
            .root
756
            .get_callbacks()
757
            .as_ref()
758
            .first()
759
            .expect("every option row must carry the click callback")
760
            .refany
761
            .clone()
762
    }
763

            
764
    // ------------------------------------------------------------------
765
    // Callback harness
766
    // ------------------------------------------------------------------
767

            
768
    /// Flattened (pre-order) node id of option row `i`: the tree is
769
    /// `root, [row, circle, dot, label <p>, label text] * n` — the label is a
770
    /// `<p>` wrapping a bare text node, per the widget label convention.
771
    fn row_node(i: usize) -> DomNodeId {
772
        node(1 + 5 * i)
773
    }
774

            
775
    /// Flattened node id of option `i`'s indicator dot.
776
    fn dot_node(i: usize) -> NodeId {
777
        NodeId::new(3 + 5 * i)
778
    }
779

            
780
    fn node(idx: usize) -> DomNodeId {
781
        DomNodeId {
782
            dom: DomId::ROOT_ID,
783
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
784
        }
785
    }
786

            
787
    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
788
    /// hit" case. `CallbackInfo::set_css_property` *panics* on such an id, so the
789
    /// handler must bail out long before reaching it.
790
    fn node_none() -> DomNodeId {
791
        DomNodeId {
792
            dom: DomId::ROOT_ID,
793
            node: NodeHierarchyItemId::NONE,
794
        }
795
    }
796

            
797
    /// A `DomLayoutResult` with an *empty* layout tree: `on_radio_row_click` only
798
    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
799
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
800
        DomLayoutResult {
801
            styled_dom,
802
            layout_tree: LayoutTree {
803
                nodes: Vec::new(),
804
                warm: Vec::new(),
805
                cold: Vec::new(),
806
                root: 0,
807
                dom_to_layout: BTreeMap::new(),
808
                children_arena: Vec::new(),
809
                children_offsets: Vec::new(),
810
                subtree_needs_intrinsic: Vec::new(),
811
            },
812
            calculated_positions: Vec::new(),
813
            viewport: LogicalRect::zero(),
814
            display_list: Arc::new(DisplayList::default()),
815
            scroll_ids: HashMap::new(),
816
            scroll_id_to_node_id: HashMap::new(),
817
        }
818
    }
819

            
820
    /// Renders `rg`, then hands back both the flattened DOM *and* the very
821
    /// `RefAny` the widget registered on row 0's mouse-up callback. Driving the
822
    /// handler with these two is the real wiring — nothing is re-created by hand,
823
    /// so a mismatch between what `dom()` stores and what the handler expects
824
    /// cannot hide behind the fixture. Requires at least one option.
825
    fn flatten(rg: RadioGroup) -> (StyledDom, RefAny) {
826
        let dom = rg.dom();
827
        let state = row_state(&dom, 0);
828
        (StyledDom::create_from_dom(dom), state)
829
    }
830

            
831
    /// Invokes `on_radio_row_click` against a `LayoutWindow` holding `styled` (or
832
    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
833
    /// Returns the `Update` plus every recorded `CallbackChange`.
834
    fn run_click(
835
        styled: Option<StyledDom>,
836
        hit: DomNodeId,
837
        data: RefAny,
838
    ) -> (Update, Vec<CallbackChange>) {
839
        let mut layout_window =
840
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
841
        if let Some(sd) = styled {
842
            layout_window
843
                .layout_results
844
                .insert(DomId::ROOT_ID, layout_result(sd));
845
        }
846

            
847
        let renderer_resources = RendererResources::default();
848
        let previous_window_state: Option<FullWindowState> = None;
849
        let current_window_state = FullWindowState::default();
850
        let gl_context = OptionGlContextPtr::None;
851
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
852
            BTreeMap::new();
853
        let window_handle = RawWindowHandle::Unsupported;
854
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
855

            
856
        let ref_data = CallbackInfoRefData {
857
            layout_window: &layout_window,
858
            renderer_resources: &renderer_resources,
859
            previous_window_state: &previous_window_state,
860
            current_window_state: &current_window_state,
861
            gl_context: &gl_context,
862
            current_scroll_manager: &scroll_states,
863
            current_window_handle: &window_handle,
864
            system_callbacks: &system_callbacks,
865
            system_style: Arc::new(SystemStyle::default()),
866
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
867
            #[cfg(feature = "icu")]
868
            icu_localizer: IcuLocalizerHandle::default(),
869
            ctx: OptionRefAny::None,
870
        };
871

            
872
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
873

            
874
        let info = CallbackInfo::new(
875
            &ref_data,
876
            &changes,
877
            hit,
878
            OptionLogicalPosition::None,
879
            OptionLogicalPosition::None,
880
        );
881

            
882
        let update = on_radio_row_click(data, info);
883
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
884
        (update, recorded)
885
    }
886

            
887
    /// The opacity overrides pushed onto individual nodes, in push order.
888
    fn pushed_opacities(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
889
        changes
890
            .iter()
891
            .filter_map(|c| match c {
892
                CallbackChange::ChangeNodeCssProperties {
893
                    node_id, properties, ..
894
                } => {
895
                    let o = properties.as_ref().iter().find_map(|p| match p {
896
                        CssProperty::Opacity(o) => o.get_property().map(|o| o.inner.normalized()),
897
                        _ => None,
898
                    })?;
899
                    Some((*node_id, o))
900
                }
901
                _ => None,
902
            })
903
            .collect()
904
    }
905

            
906
    /// What a correct restyle of an `n`-option group with option `selected` looks
907
    /// like: every dot touched exactly once, only the selected one opaque.
908
    fn expected_opacities(n: usize, selected: usize) -> Vec<(NodeId, f32)> {
909
        (0..n)
910
            .map(|i| (dot_node(i), if i == selected { 1.0 } else { 0.0 }))
911
            .collect()
912
    }
913

            
914
    fn selected_index_of(data: &mut RefAny) -> usize {
915
        data.downcast_ref::<RadioGroupStateWrapper>()
916
            .expect("payload must still be a RadioGroupStateWrapper")
917
            .inner
918
            .selected_index
919
    }
920

            
921
    /// A `RefAny` payload recording every index a user `on_change` sees.
922
    struct ChangeLog {
923
        seen: Vec<usize>,
924
    }
925

            
926
    extern "C" fn record_change(
927
        mut data: RefAny,
928
        _: CallbackInfo,
929
        state: RadioGroupState,
930
    ) -> Update {
931
        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
932
            log.seen.push(state.selected_index);
933
        }
934
        Update::RefreshDom
935
    }
936

            
937
    extern "C" fn change_do_nothing(_: RefAny, _: CallbackInfo, _: RadioGroupState) -> Update {
938
        Update::DoNothing
939
    }
940

            
941
    extern "C" fn change_refresh_all(_: RefAny, _: CallbackInfo, _: RadioGroupState) -> Update {
942
        Update::RefreshDomAllWindows
943
    }
944

            
945
    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
946
    fn change_cb(f: RadioGroupOnChangeCallbackType) -> RadioGroupOnChangeCallback {
947
        f.into()
948
    }
949

            
950
    fn log_refany() -> RefAny {
951
        RefAny::new(ChangeLog { seen: Vec::new() })
952
    }
953

            
954
    fn log_indices(data: &mut RefAny) -> Vec<usize> {
955
        data.downcast_ref::<ChangeLog>()
956
            .expect("payload must still be a ChangeLog")
957
            .seen
958
            .clone()
959
    }
960

            
961
    // ==================================================================
962
    // build_container_style
963
    // ==================================================================
964

            
965
    #[test]
966
    fn container_style_switches_only_the_flex_direction() {
967
        // Orientation is the *only* thing this function is allowed to vary; if it
968
        // also flipped, say, align-self, a horizontal group would stretch across
969
        // the parent while a vertical one hugs its content.
970
        let vertical = build_container_style(false);
971
        let horizontal = build_container_style(true);
972

            
973
        let v = props(vertical.as_ref());
974
        let h = props(horizontal.as_ref());
975
        assert_eq!(
976
            v.len(),
977
            h.len(),
978
            "the two orientations declare a different number of properties",
979
        );
980

            
981
        let differing: Vec<_> = v
982
            .iter()
983
            .zip(h.iter())
984
            .filter(|(a, b)| a != b)
985
            .map(|(a, _)| discriminant(a))
986
            .collect();
987
        assert_eq!(
988
            differing,
989
            vec![discriminant(&CssProperty::const_flex_direction(
990
                LayoutFlexDirection::Row
991
            ))],
992
            "the vertical/horizontal container styles differ in more than the flex direction",
993
        );
994

            
995
        assert_eq!(
996
            flex_direction(&v),
997
            Some(LayoutFlexDirection::Column),
998
            "a vertical radio group must stack its options",
999
        );
        assert_eq!(
            flex_direction(&h),
            Some(LayoutFlexDirection::Row),
            "a horizontal radio group must lay its options side by side",
        );
    }
    #[test]
    fn container_style_is_pure_unconditional_and_declares_nothing_twice() {
        for horizontal in [false, true] {
            let a = build_container_style(horizontal);
            let b = build_container_style(horizontal);
            assert_eq!(
                a.as_ref(),
                b.as_ref(),
                "build_container_style({horizontal}) is not a pure function",
            );
            no_duplicate_properties("the container style", a.as_ref());
            assert!(
                all_unconditional(a.as_ref()),
                "the container style must apply unconditionally",
            );
        }
    }
    #[test]
    fn container_style_is_a_non_growing_flex_box_in_both_orientations() {
        // `flex-grow: 0` + `align-self: start` is what keeps the group hugging its
        // options instead of being stretched by the parent flex line.
        for horizontal in [false, true] {
            let style = build_container_style(horizontal);
            assert!(
                has_property(
                    style.as_ref(),
                    &CssProperty::const_display(LayoutDisplay::Flex)
                ),
                "horizontal={horizontal}: the container is not a flex box",
            );
            assert!(
                has_property(
                    style.as_ref(),
                    &CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
                ),
                "horizontal={horizontal}: the container would be stretched by its parent",
            );
            assert!(
                has_property(
                    style.as_ref(),
                    &CssProperty::align_self(LayoutAlignSelf::Start)
                ),
                "horizontal={horizontal}: the container lost its align-self:start",
            );
        }
    }
    // ==================================================================
    // build_row_style
    // ==================================================================
    #[test]
    fn row_style_puts_the_inter_row_gap_on_the_stacking_axis() {
        // Vertical groups stack downwards -> the gap belongs on the bottom;
        // horizontal groups run rightwards -> it belongs on the right. Putting it
        // on the wrong axis leaves the options touching along the axis they are
        // actually laid out on.
        let vertical = props(build_row_style(false).as_ref());
        assert_eq!(
            margin_bottom(&vertical).map(px),
            Some(ROW_GAP as f32),
            "a vertically stacked row must separate itself from the next one",
        );
        assert_eq!(
            margin_right(&vertical),
            None,
            "a vertically stacked row must not push its neighbours sideways",
        );
        let horizontal = props(build_row_style(true).as_ref());
        assert_eq!(
            margin_right(&horizontal).map(px),
            Some((ROW_GAP * 2) as f32),
            "a horizontal row must separate itself from the next one",
        );
        assert_eq!(
            margin_bottom(&horizontal),
            None,
            "a horizontal row must not add vertical spacing",
        );
    }
    #[test]
    fn row_style_is_always_an_inner_row_regardless_of_the_group_orientation() {
        // The *group* orientation must not leak into the row: a row is always
        // `circle | label` left-to-right, even inside a column group. A naive
        // "pass horizontal through" would render the label under the indicator.
        for horizontal in [false, true] {
            let style = props(build_row_style(horizontal).as_ref());
            assert_eq!(
                flex_direction(&style),
                Some(LayoutFlexDirection::Row),
                "horizontal={horizontal}: the indicator/label pair is not laid out in a row",
            );
            assert!(
                has_property(
                    build_row_style(horizontal).as_ref(),
                    &CssProperty::const_align_items(LayoutAlignItems::Center)
                ),
                "horizontal={horizontal}: the label is not vertically centred on the indicator",
            );
        }
    }
    #[test]
    fn row_style_marks_the_whole_row_as_a_click_target() {
        // The row is what carries the mouse-up handler, so it must *look*
        // clickable and must not start a text selection when dragged.
        for horizontal in [false, true] {
            let style = props(build_row_style(horizontal).as_ref());
            assert_eq!(
                cursor(&style),
                Some(StyleCursor::Pointer),
                "horizontal={horizontal}: the row does not look clickable",
            );
            assert_eq!(
                user_select(&style),
                Some(StyleUserSelect::None),
                "horizontal={horizontal}: dragging a row would select its label text",
            );
        }
    }
    #[test]
    fn row_style_is_pure_unconditional_and_declares_nothing_twice() {
        for horizontal in [false, true] {
            let a = build_row_style(horizontal);
            let b = build_row_style(horizontal);
            assert_eq!(
                a.as_ref(),
                b.as_ref(),
                "build_row_style({horizontal}) is not a pure function",
            );
            no_duplicate_properties("the row style", a.as_ref());
            assert!(
                all_unconditional(a.as_ref()),
                "the row style must apply unconditionally",
            );
        }
    }
    // ==================================================================
    // The const style tables
    // ==================================================================
    #[test]
    fn the_const_style_tables_declare_nothing_twice_and_apply_unconditionally() {
        for (name, style) in [
            ("the circle style", RADIO_GROUP_CIRCLE_STYLE),
            ("the selected dot style", RADIO_GROUP_DOT_STYLE_SELECTED),
            ("the unselected dot style", RADIO_GROUP_DOT_STYLE_UNSELECTED),
            ("the label style", RADIO_GROUP_LABEL_STYLE),
        ] {
            no_duplicate_properties(name, style);
            assert!(all_unconditional(style), "{name} must apply unconditionally");
        }
    }
    #[test]
    fn the_two_dot_styles_differ_in_opacity_and_nothing_else() {
        // Opacity is the *only* thing that may distinguish a selected option from
        // an unselected one: a size or colour difference would reflow (or recolour)
        // the row as the selection moves.
        let selected = props(RADIO_GROUP_DOT_STYLE_SELECTED);
        let unselected = props(RADIO_GROUP_DOT_STYLE_UNSELECTED);
        assert_eq!(
            selected.len(),
            unselected.len(),
            "the two dot styles declare a different number of properties",
        );
        let differing: Vec<_> = selected
            .iter()
            .zip(unselected.iter())
            .filter(|(a, b)| a != b)
            .map(|(a, _)| discriminant(a))
            .collect();
        assert_eq!(
            differing,
            vec![discriminant(&CssProperty::const_opacity(
                StyleOpacity::const_new(0)
            ))],
            "the selected/unselected dot styles differ in something other than opacity",
        );
        assert_eq!(
            opacity_of(&selected),
            Some(1.0),
            "the selected dot is not fully opaque (const_new takes a *percentage*)",
        );
        assert_eq!(
            opacity_of(&unselected),
            Some(0.0),
            "the unselected dot is still visible",
        );
    }
    #[test]
    fn the_indicator_geometry_is_absolute_px_and_actually_circular() {
        // `border-radius = size / 2` on all four corners is what makes the ring and
        // the dot circles rather than rounded squares; and the dot plus the ring's
        // two borders must fit inside the ring.
        assert_eq!(CIRCLE_RADIUS * 2, CIRCLE_SIZE, "the ring is not a circle");
        assert_eq!(DOT_RADIUS * 2, DOT_SIZE, "the dot is not a circle");
        assert!(
            DOT_SIZE + 2 * CIRCLE_BORDER <= CIRCLE_SIZE,
            "the dot ({DOT_SIZE}px) does not fit inside the ring ({CIRCLE_SIZE}px + \
             {CIRCLE_BORDER}px borders)",
        );
        let circle = props(RADIO_GROUP_CIRCLE_STYLE);
        assert_eq!(width(&circle).map(px), Some(CIRCLE_SIZE as f32));
        assert_eq!(height(&circle).map(px), Some(CIRCLE_SIZE as f32));
        assert_eq!(
            border_top_left_radius(&circle).map(px),
            Some(CIRCLE_RADIUS as f32),
        );
        for style in [RADIO_GROUP_DOT_STYLE_SELECTED, RADIO_GROUP_DOT_STYLE_UNSELECTED] {
            let dot = props(style);
            assert_eq!(width(&dot).map(px), Some(DOT_SIZE as f32));
            assert_eq!(height(&dot).map(px), Some(DOT_SIZE as f32));
            assert_eq!(
                border_top_left_radius(&dot).map(px),
                Some(DOT_RADIUS as f32),
            );
        }
        assert_eq!(
            margin_left(&props(RADIO_GROUP_LABEL_STYLE)).map(px),
            Some(LABEL_GAP as f32),
            "the label lost its gap from the indicator",
        );
    }
    // ==================================================================
    // RadioGroup::create
    // ==================================================================
    #[test]
    fn create_preserves_the_options_verbatim_and_defaults_the_state() {
        for case in [
            vec![],
            vec!["only"],
            vec!["a", "b"],
            vec!["dup", "dup", "dup"],
            vec!["Yes", "No", "Maybe", "Ask again later"],
        ] {
            let rg = group(&case);
            let got: Vec<&str> = rg.options.as_ref().iter().map(AzString::as_str).collect();
            assert_eq!(got, case, "create must not reorder/drop/rewrite options");
            assert_eq!(
                rg.radio_group_state.inner.selected_index, 0,
                "a fresh radio group selects its first option",
            );
            assert!(
                !rg.radio_group_state.horizontal,
                "a fresh radio group is vertical",
            );
            assert!(
                rg.radio_group_state.on_change.as_ref().is_none(),
                "create must not invent a callback",
            );
            assert_eq!(
                rg.container_style.as_ref(),
                build_container_style(false).as_ref(),
                "create must build the *vertical* container style",
            );
        }
    }
    #[test]
    fn create_survives_pathological_labels() {
        // empty string, whitespace-only, emoji + ZWJ, RTL, stacked combining marks,
        // an embedded NUL, invisible formatting chars, and a 100k-char label.
        let huge = "x".repeat(100_000);
        let case = vec![
            "",
            "   ",
            "a\u{0}b",
            "👨‍👩‍👧‍👦",
            "مرحبا",
            "e\u{0301}\u{0301}\u{0301}",
            "\u{200b}\u{feff}",
            huge.as_str(),
        ];
        let rg = group(&case);
        let got: Vec<&str> = rg.options.as_ref().iter().map(AzString::as_str).collect();
        assert_eq!(got, case, "options must survive byte-for-byte");
        assert_eq!(rg.options.as_ref()[7].as_str().len(), 100_000);
        // … and they must survive the trip through the DOM unchanged.
        let dom = rg.dom();
        let texts: Vec<&str> = (0..case.len()).filter_map(|i| text_of(label_of(&dom, i))).collect();
        assert_eq!(texts, case, "a label was mangled on its way into the DOM");
    }
    #[test]
    fn create_with_a_huge_option_list_does_not_panic() {
        let n = 10_000;
        let rg = RadioGroup::create(n_labels(n));
        assert_eq!(rg.options.as_ref().len(), n);
        assert_eq!(rg.options.as_ref()[n - 1].as_str(), "o9999");
    }
    #[test]
    fn default_equals_create_with_no_options() {
        assert_eq!(
            RadioGroup::default(),
            RadioGroup::create(StringVec::from_const_slice(&[])),
        );
    }
    // ==================================================================
    // set_selected_index / with_selected_index
    // ==================================================================
    #[test]
    fn selected_index_is_stored_verbatim_at_every_boundary() {
        // The setter is documented as a plain store — no clamping to the option
        // count — so the extremes must round-trip exactly rather than saturate,
        // wrap, or panic in a debug build.
        for idx in [0, 1, 2, 3, 1_000, usize::MAX - 1, usize::MAX] {
            let mut rg = group(&["a", "b", "c"]);
            rg.set_selected_index(idx);
            assert_eq!(
                rg.radio_group_state.inner.selected_index, idx,
                "set_selected_index({idx}) did not store what it was given",
            );
            let built = group(&["a", "b", "c"]).with_selected_index(idx);
            assert_eq!(
                built, rg,
                "with_selected_index({idx}) disagrees with the mutating setter",
            );
        }
    }
    #[test]
    fn setting_the_index_repeatedly_keeps_only_the_last_value() {
        let mut rg = group(&["a", "b"]);
        for idx in [1, 0, usize::MAX, 1, 0] {
            rg.set_selected_index(idx);
        }
        assert_eq!(rg.radio_group_state.inner.selected_index, 0);
    }
    #[test]
    fn with_selected_index_touches_nothing_but_the_index() {
        let before = group(&["a", "b", "c"]).with_horizontal(true);
        let after = before.clone().with_selected_index(2);
        assert_eq!(after.options.as_ref(), before.options.as_ref());
        assert_eq!(after.container_style.as_ref(), before.container_style.as_ref());
        assert_eq!(
            after.radio_group_state.horizontal,
            before.radio_group_state.horizontal,
            "changing the selection must not change the layout direction",
        );
        assert_eq!(after.radio_group_state.inner.selected_index, 2);
    }
    #[test]
    fn an_out_of_range_selection_renders_every_dot_hidden() {
        // Nothing clamps `selected_index`, so `dom()` has to cope with an index no
        // option owns: it must render the full option list with *no* dot lit
        // rather than panicking or highlighting a wrapped-around row.
        for idx in [3, 4, 1_000, usize::MAX - 1, usize::MAX] {
            let dom = group(&["a", "b", "c"]).with_selected_index(idx).dom();
            assert_eq!(
                dom.children.as_ref().len(),
                3,
                "idx={idx}: an out-of-range selection changed the option count",
            );
            for i in 0..3 {
                assert_eq!(
                    opacity_of(&inline_props(dot_of(&dom, i))),
                    Some(0.0),
                    "idx={idx}: option {i} is lit even though nothing is selected",
                );
            }
        }
    }
    #[test]
    fn selecting_an_option_lights_exactly_that_one() {
        for selected in 0..4 {
            let dom = group(&["a", "b", "c", "d"])
                .with_selected_index(selected)
                .dom();
            let lit: Vec<usize> = (0..4)
                .filter(|i| opacity_of(&inline_props(dot_of(&dom, *i))) == Some(1.0))
                .collect();
            assert_eq!(
                lit,
                vec![selected],
                "selecting option {selected} lit {lit:?} instead",
            );
        }
    }
    // ==================================================================
    // set_horizontal / with_horizontal
    // ==================================================================
    #[test]
    fn the_horizontal_flag_and_the_container_style_never_disagree() {
        // Two sources of truth for one fact: the flag drives the *rendered* row
        // style, the style drives the container. If the setter updated only one of
        // them, a group would stack vertically while spacing itself horizontally.
        for horizontal in [false, true] {
            let mut rg = group(&["a", "b"]);
            rg.set_horizontal(horizontal);
            assert_eq!(rg.radio_group_state.horizontal, horizontal);
            assert_eq!(
                rg.container_style.as_ref(),
                build_container_style(horizontal).as_ref(),
                "horizontal={horizontal}: the container style was not rebuilt",
            );
            assert_eq!(
                group(&["a", "b"]).with_horizontal(horizontal),
                rg,
                "with_horizontal({horizontal}) disagrees with the mutating setter",
            );
        }
    }
    #[test]
    fn toggling_the_orientation_never_accumulates_properties() {
        // The style is *rebuilt*, not appended to: flipping the flag a hundred
        // times must leave a four-property vec, not a four-hundred-property one
        // (where every later duplicate silently overrides the earlier).
        let mut rg = group(&["a", "b"]);
        let original = rg.clone();
        let len = rg.container_style.as_ref().len();
        for i in 0..100 {
            rg.set_horizontal(i % 2 == 0);
            assert_eq!(
                rg.container_style.as_ref().len(),
                len,
                "toggle #{i}: the container style grew",
            );
        }
        rg.set_horizontal(false);
        assert_eq!(
            rg, original,
            "an even number of toggles did not return the group to its original state",
        );
    }
    #[test]
    fn the_orientation_reaches_the_rendered_container() {
        for (horizontal, expected) in [
            (false, LayoutFlexDirection::Column),
            (true, LayoutFlexDirection::Row),
        ] {
            let dom = group(&["a", "b"]).with_horizontal(horizontal).dom();
            assert_eq!(
                flex_direction(&inline_props(&dom)),
                Some(expected),
                "horizontal={horizontal}: the rendered container flows the wrong way",
            );
        }
    }
    #[test]
    fn the_orientation_reaches_the_rendered_rows() {
        // `dom()` reads the *flag*, not the container style, to build the row gap —
        // so the flag has to be what `set_horizontal` stored.
        for horizontal in [false, true] {
            let dom = group(&["a", "b"]).with_horizontal(horizontal).dom();
            let row = inline_props(row_of(&dom, 0));
            assert_eq!(
                margin_bottom(&row),
                margin_bottom(&props(build_row_style(horizontal).as_ref())),
            );
            assert_eq!(
                margin_right(&row),
                margin_right(&props(build_row_style(horizontal).as_ref())),
            );
        }
    }
    // ==================================================================
    // swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_hands_back_the_old_value_and_leaves_a_default_behind() {
        let mut rg = group(&["a", "b", "c"])
            .with_selected_index(2)
            .with_horizontal(true);
        let original = rg.clone();
        let taken = rg.swap_with_default();
        assert_eq!(taken, original, "the caller did not get the old value back");
        assert_eq!(
            rg,
            RadioGroup::default(),
            "the widget was not reset to its default",
        );
        assert!(rg.options.as_ref().is_empty());
        assert_eq!(rg.radio_group_state.inner.selected_index, 0);
        assert!(!rg.radio_group_state.horizontal);
    }
    #[test]
    fn swapping_twice_restores_the_original() {
        let mut rg = group(&["a", "b"]).with_selected_index(1);
        let original = rg.clone();
        let mut taken = rg.swap_with_default();
        let back = taken.swap_with_default();
        assert_eq!(back, original, "swap is not its own inverse");
        assert_eq!(taken, RadioGroup::default());
    }
    #[test]
    fn swapping_a_default_group_is_a_no_op() {
        let mut rg = RadioGroup::default();
        let taken = rg.swap_with_default();
        assert_eq!(taken, RadioGroup::default());
        assert_eq!(rg, RadioGroup::default());
    }
    #[test]
    fn swap_with_default_drops_the_installed_callback_from_the_widget() {
        // The callback belongs to the value that was taken, not to the husk left
        // behind — otherwise the "default" group would still fire the old handler.
        let mut rg = group(&["a"]).with_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
        let taken = rg.swap_with_default();
        assert!(taken.radio_group_state.on_change.as_ref().is_some());
        assert!(
            rg.radio_group_state.on_change.as_ref().is_none(),
            "the emptied widget kept the old on_change callback",
        );
    }
    // ==================================================================
    // set_on_change / with_on_change
    // ==================================================================
    #[test]
    fn with_on_change_installs_the_callback_and_keeps_the_rest_of_the_state() {
        let before = group(&["a", "b", "c"])
            .with_selected_index(2)
            .with_horizontal(true);
        // One shared payload: `RefAny` equality is instance identity, so the
        // builder/setter comparison below only means something with the same one.
        let payload = RefAny::new(7u32);
        let after = before
            .clone()
            .with_on_change(payload.clone(), change_cb(record_change));
        assert!(after.radio_group_state.on_change.as_ref().is_some());
        assert_eq!(after.options.as_ref(), before.options.as_ref());
        assert_eq!(after.container_style.as_ref(), before.container_style.as_ref());
        assert_eq!(after.radio_group_state.inner, before.radio_group_state.inner);
        assert_eq!(
            after.radio_group_state.horizontal,
            before.radio_group_state.horizontal,
        );
        // … and it matches the mutating setter.
        let mut mutated = before;
        mutated.set_on_change(payload, change_cb(record_change));
        assert_eq!(mutated, after);
    }
    #[test]
    fn setting_on_change_twice_replaces_it_rather_than_stacking() {
        let mut rg = group(&["a"]);
        rg.set_on_change(RefAny::new(1u8), change_cb(record_change));
        rg.set_on_change(RefAny::new(2u8), change_cb(change_refresh_all));
        let installed = rg
            .radio_group_state
            .on_change
            .as_ref()
            .expect("a callback must still be installed");
        assert_eq!(
            installed.callback,
            change_cb(change_refresh_all),
            "the first callback survived the second install",
        );
        let mut payload = installed.refany.clone();
        assert_eq!(
            *payload.downcast_ref::<u8>().expect("the payload changed type"),
            2,
            "the first payload survived the second install",
        );
    }
    #[test]
    fn installing_a_callback_never_invokes_it() {
        // Building a widget is not a user interaction: nothing may fire until a
        // click actually happens.
        let mut probe = log_refany();
        let rg = group(&["a", "b"]).with_on_change(probe.clone(), change_cb(record_change));
        let dom = rg.dom();
        let _ = StyledDom::create_from_dom(dom);
        assert!(
            log_indices(&mut probe).is_empty(),
            "the on_change callback fired during construction",
        );
    }
    // ==================================================================
    // RadioGroup::dom
    // ==================================================================
    #[test]
    fn dom_renders_one_row_per_option_with_the_documented_structure() {
        let dom = group(&["a", "b", "c"]).dom();
        assert_eq!(classes(&dom), vec!["__azul-native-radio-group"]);
        assert_eq!(dom.children.as_ref().len(), 3, "one row per option");
        for i in 0..3 {
            let row = row_of(&dom, i);
            assert_eq!(classes(row), vec!["__azul-native-radio-group-row"]);
            assert_eq!(
                row.children.as_ref().len(),
                2,
                "row {i} must be `circle, label`",
            );
            assert_eq!(
                row.root.get_tab_index(),
                Some(TabIndex::Auto),
                "row {i} is not keyboard reachable",
            );
            let circle = &row.children.as_ref()[0];
            assert_eq!(classes(circle), vec!["__azul-native-radio-group-circle"]);
            assert_eq!(circle.children.as_ref().len(), 1, "the circle holds the dot");
            assert_eq!(
                classes(dot_of(&dom, i)),
                vec!["__azul-native-radio-group-dot"],
            );
            assert_eq!(
                classes(label_of(&dom, i)),
                vec!["__azul-native-radio-group-label"],
            );
            assert_eq!(text_of(label_of(&dom, i)), Some(["a", "b", "c"][i]));
        }
    }
    #[test]
    fn every_row_carries_exactly_one_mouse_up_handler_pointing_at_the_row_handler() {
        let dom = group(&["a", "b", "c"]).dom();
        for i in 0..3 {
            let cbs = row_of(&dom, i).root.get_callbacks();
            assert_eq!(cbs.as_ref().len(), 1, "row {i} must have one callback");
            let cb = &cbs.as_ref()[0];
            assert_eq!(
                cb.event,
                EventFilter::Hover(HoverEventFilter::MouseUp),
                "row {i} listens for the wrong event",
            );
            assert_eq!(
                cb.callback.cb,
                on_radio_row_click as usize,
                "row {i} is wired to the wrong handler",
            );
        }
        // The inner nodes must stay inert: a handler on the dot or the label would
        // resolve its index against the *wrong* sibling set.
        for i in 0..3 {
            assert!(row_of(&dom, i).children.as_ref()[0]
                .root
                .get_callbacks()
                .as_ref()
                .is_empty());
            assert!(dot_of(&dom, i).root.get_callbacks().as_ref().is_empty());
            assert!(label_of(&dom, i).root.get_callbacks().as_ref().is_empty());
        }
    }
    #[test]
    fn all_rows_share_one_state_refany() {
        // Mutual exclusion depends on it: if each row owned its own copy of the
        // state, clicking row 2 would leave row 0 still believing it is selected.
        let dom = group(&["a", "b", "c", "d"]).dom();
        let first = row_state(&dom, 0);
        for i in 1..4 {
            assert_eq!(
                row_state(&dom, i).get_data_ptr(),
                first.get_data_ptr(),
                "row {i} carries its own state instead of the shared one",
            );
        }
    }
    #[test]
    fn dom_of_an_empty_group_is_an_empty_container() {
        let dom = RadioGroup::default().dom();
        assert!(
            dom.children.as_ref().is_empty(),
            "a group with no options invented a row",
        );
        assert!(dom.root.get_callbacks().as_ref().is_empty());
        assert_eq!(classes(&dom), vec!["__azul-native-radio-group"]);
    }
    #[test]
    fn dom_of_an_empty_group_with_a_selection_does_not_panic() {
        // `create` sets index 0 even with zero options, so the "selected option" is
        // out of range from the start — the render path must not index into it.
        for idx in [0, 1, usize::MAX] {
            let dom = RadioGroup::default().with_selected_index(idx).dom();
            assert!(dom.children.as_ref().is_empty());
        }
    }
    #[test]
    fn dom_flattens_to_five_nodes_per_option() {
        // root + (row, circle, dot, label <p>, label text) per option. The click
        // handler's live restyle walks exactly this shape, and the callback tests
        // below address nodes by this formula.
        for n in [0, 1, 2, 7] {
            let styled = StyledDom::create_from_dom(RadioGroup::create(n_labels(n)).dom());
            assert_eq!(
                styled.node_hierarchy.as_ref().len(),
                1 + 5 * n,
                "an {n}-option group flattened to an unexpected node count",
            );
        }
    }
    #[test]
    fn a_large_group_renders_without_panicking() {
        let n = 500;
        let dom = RadioGroup::create(n_labels(n))
            .with_selected_index(n - 1)
            .dom();
        assert_eq!(dom.children.as_ref().len(), n);
        assert_eq!(text_of(label_of(&dom, n - 1)), Some("o499"));
        assert_eq!(opacity_of(&inline_props(dot_of(&dom, n - 1))), Some(1.0));
        assert_eq!(opacity_of(&inline_props(dot_of(&dom, 0))), Some(0.0));
    }
    // ==================================================================
    // on_radio_row_click
    // ==================================================================
    #[test]
    fn clicking_a_row_selects_it_and_restyles_every_dot() {
        for clicked in 0..4 {
            let (styled, state) = flatten(group(&["a", "b", "c", "d"]));
            let mut state_probe = state.clone();
            let (update, changes) = run_click(Some(styled), row_node(clicked), state);
            assert_eq!(
                update,
                Update::DoNothing,
                "with no on_change installed the handler reports nothing to redraw",
            );
            assert_eq!(
                selected_index_of(&mut state_probe),
                clicked,
                "clicking row {clicked} selected the wrong option",
            );
            assert_eq!(
                pushed_opacities(&changes),
                expected_opacities(4, clicked),
                "clicking row {clicked} did not light exactly that row's dot",
            );
        }
    }
    #[test]
    fn clicking_the_already_selected_row_is_idempotent() {
        let (styled, state) = flatten(group(&["a", "b", "c"]).with_selected_index(1));
        let mut probe = state.clone();
        let (_, changes) = run_click(Some(styled), row_node(1), state);
        assert_eq!(selected_index_of(&mut probe), 1);
        assert_eq!(
            pushed_opacities(&changes),
            expected_opacities(3, 1),
            "a redundant click must still leave every dot in a consistent state",
        );
    }
    #[test]
    fn clicking_repairs_an_out_of_range_selection() {
        // The widget can be handed an index no option owns; the first click must
        // bring it back into range instead of leaving a group with nothing lit.
        let (styled, state) = flatten(group(&["a", "b", "c"]).with_selected_index(usize::MAX));
        let mut probe = state.clone();
        let (_, changes) = run_click(Some(styled), row_node(2), state);
        assert_eq!(selected_index_of(&mut probe), 2);
        assert_eq!(pushed_opacities(&changes), expected_opacities(3, 2));
    }
    #[test]
    fn clicking_a_single_option_group_selects_option_zero() {
        let (styled, state) = flatten(group(&["only"]));
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled), row_node(0), state);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(selected_index_of(&mut probe), 0);
        assert_eq!(pushed_opacities(&changes), vec![(dot_node(0), 1.0)]);
    }
    #[test]
    fn the_reported_index_always_addresses_a_real_option() {
        let n = 32;
        for clicked in [0, 1, n / 2, n - 2, n - 1] {
            let (styled, state) = flatten(RadioGroup::create(n_labels(n)));
            let mut probe = state.clone();
            let (_, changes) = run_click(Some(styled), row_node(clicked), state);
            let idx = selected_index_of(&mut probe);
            assert!(idx < n, "row {clicked} reported out-of-range index {idx}");
            assert_eq!(idx, clicked);
            let pushed = pushed_opacities(&changes);
            assert_eq!(pushed.len(), n, "every dot must be restyled exactly once");
            assert_eq!(
                pushed.iter().filter(|(_, o)| *o == 1.0).count(),
                1,
                "exactly one option may be lit at a time",
            );
        }
    }
    #[test]
    fn the_user_callback_sees_the_new_index_and_its_update_is_forwarded() {
        // Order matters: the selection is written *before* the user callback runs,
        // so the callback observes the state the user just asked for.
        let mut probe = log_refany();
        let rg = group(&["a", "b", "c"]).with_on_change(probe.clone(), change_cb(record_change));
        let (styled, state) = flatten(rg);
        let (update, changes) = run_click(Some(styled), row_node(2), state.clone());
        assert_eq!(log_indices(&mut probe), vec![2], "the callback ran once with the new index");
        assert_eq!(update, Update::RefreshDom, "the user's Update was swallowed");
        // … and the restyle still happens *after* the user callback returns.
        assert_eq!(pushed_opacities(&changes), expected_opacities(3, 2));
        // A second click updates the shared state again — the index is not sticky,
        // and the user hears about every click, not just the first.
        let (styled2, _) = flatten(group(&["a", "b", "c"]));
        let (_, _) = run_click(Some(styled2), row_node(0), state.clone());
        assert_eq!(log_indices(&mut probe), vec![2, 0]);
        let mut state = state;
        assert_eq!(
            selected_index_of(&mut state),
            0,
            "the state must hold the *last* clicked index",
        );
    }
    #[test]
    fn a_callback_that_declines_the_update_still_gets_the_dots_restyled() {
        // A user callback returning DoNothing must not suppress the widget's own
        // visual bookkeeping — otherwise the state says "option 1" while option 0
        // stays lit.
        let rg = group(&["a", "b"]).with_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
        let (styled, state) = flatten(rg);
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled), row_node(1), state);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(selected_index_of(&mut probe), 1);
        assert_eq!(
            pushed_opacities(&changes),
            expected_opacities(2, 1),
            "a DoNothing user callback suppressed the dot restyle",
        );
    }
    #[test]
    fn every_update_variant_is_propagated_unchanged() {
        for (cb, expected) in [
            (change_cb(change_do_nothing), Update::DoNothing),
            (change_cb(change_refresh_all), Update::RefreshDomAllWindows),
            (change_cb(record_change), Update::RefreshDom),
        ] {
            let rg = group(&["a", "b"]).with_on_change(log_refany(), cb);
            let (styled, state) = flatten(rg);
            let (update, _) = run_click(Some(styled), row_node(1), state);
            assert_eq!(update, expected);
        }
    }
    #[test]
    fn clicking_the_root_container_does_nothing() {
        // The root has no parent -> the handler must bail before indexing into
        // nothing.
        let (styled, state) = flatten(group(&["a", "b"]));
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled), node(0), state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a parentless hit pushed a DOM change");
        assert_eq!(selected_index_of(&mut probe), 0, "the state must be untouched");
    }
    #[test]
    fn clicking_a_stale_or_absent_node_does_nothing() {
        // Stale hit ids reach callbacks after a DOM mutation, and
        // `set_css_property` *panics* on a None node id — so the handler has to
        // bail out well before the restyle loop.
        for hit in [node(9999), node(usize::MAX - 1), node_none()] {
            let (styled, state) = flatten(group(&["a", "b"]).with_selected_index(1));
            let mut probe = state.clone();
            let (update, changes) = run_click(Some(styled), hit, state);
            assert_eq!(update, Update::DoNothing, "{hit:?}: a stale hit was acted on");
            assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
            assert_eq!(
                selected_index_of(&mut probe),
                1,
                "{hit:?}: a stale hit moved the selection",
            );
        }
    }
    #[test]
    fn clicking_with_no_layout_result_does_nothing() {
        let dom = group(&["a", "b"]).dom();
        let state = row_state(&dom, 0);
        let (update, changes) = run_click(None, row_node(0), state);
        assert_eq!(
            update,
            Update::DoNothing,
            "an empty LayoutWindow must be handled, not unwrapped",
        );
        assert!(changes.is_empty());
    }
    #[test]
    fn clicking_with_a_foreign_payload_does_nothing_and_leaves_it_intact() {
        // The handler downcasts blind; a foreign RefAny must bail out, not
        // reinterpret the bytes as a RadioGroupStateWrapper.
        let (styled, _) = flatten(group(&["a", "b"]));
        let foreign = RefAny::new(0xDEAD_BEEF_u32);
        let (update, changes) = run_click(Some(styled), row_node(1), foreign.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "the handler restyled the DOM through a RefAny it could not read",
        );
        let mut foreign = foreign;
        assert_eq!(
            *foreign
                .downcast_ref::<u32>()
                .expect("the foreign payload was reinterpreted"),
            0xDEAD_BEEF,
            "the handler corrupted a RefAny it did not understand",
        );
    }
    #[test]
    fn clicking_while_the_state_is_already_borrowed_does_nothing() {
        let (styled, state) = flatten(group(&["a", "b"]));
        // A live mutable borrow on a sibling clone: `downcast_mut` inside the
        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
        let mut held = state.clone();
        let guard = held
            .downcast_mut::<RadioGroupStateWrapper>()
            .expect("first borrow succeeds");
        let (update, changes) = run_click(Some(styled), row_node(1), state);
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "the handler restyled the DOM after failing to update the state",
        );
        drop(guard);
    }
    #[test]
    fn a_hit_inside_a_row_resolves_against_its_own_siblings() {
        // The handler documents `currentTarget` semantics: the hit node is the row
        // the callback is registered on, and only rows carry callbacks. Should an
        // inner node ever reach it anyway, it must stay memory-safe and push no
        // half-finished restyle — the sibling walk simply finds no dots to update.
        // (`dot` is its circle's only child -> position 0; the label `<p>` is its
        // row's second child -> position 1, regardless of which row it belongs to.)
        for (hit, expected) in [(node(3), 0usize), (node(13), 0), (node(4), 1), (node(14), 1)] {
            let (styled, state) = flatten(group(&["a", "b", "c"]));
            let mut probe = state.clone();
            let (update, changes) = run_click(Some(styled), hit, state);
            assert_eq!(update, Update::DoNothing);
            assert_eq!(selected_index_of(&mut probe), expected, "{hit:?}");
            assert!(
                changes.is_empty(),
                "{hit:?}: an inner-node hit pushed a partial restyle",
            );
        }
    }
    #[test]
    fn many_clicks_keep_the_state_and_the_pushed_opacities_in_agreement() {
        // A drift between the stored index and the pushed opacity is exactly the
        // class of bug that makes a radio group render a selection it does not
        // hold. 60 clicks cycling through a 5-option group.
        let (_, state) = flatten(group(&["a", "b", "c", "d", "e"]));
        for click in 0..60usize {
            let expected = click % 5;
            let (styled, _) = flatten(group(&["a", "b", "c", "d", "e"]));
            let (_, changes) = run_click(Some(styled), row_node(expected), state.clone());
            let mut probe = state.clone();
            assert_eq!(
                selected_index_of(&mut probe),
                expected,
                "click #{click}: the stored index drifted",
            );
            assert_eq!(
                pushed_opacities(&changes),
                expected_opacities(5, expected),
                "click #{click}: the pushed opacities disagree with the stored index",
            );
        }
    }
}