Skip to main content

azul_layout/widgets/
radio_group.rs

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
16use std::vec::Vec;
17
18use azul_core::{
19    callbacks::{CoreCallbackData, Update},
20    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
21    refany::RefAny,
22};
23use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
24use 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
34use crate::callbacks::{Callback, CallbackInfo};
35
36static RADIO_GROUP_CLASS: &[IdOrClass] =
37    &[Class(AzString::from_const_str("__azul-native-radio-group"))];
38static RADIO_GROUP_ROW_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
39    "__azul-native-radio-group-row",
40))];
41static RADIO_GROUP_CIRCLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
42    "__azul-native-radio-group-circle",
43))];
44static RADIO_GROUP_DOT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
45    "__azul-native-radio-group-dot",
46))];
47static 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.
52pub type RadioGroupOnChangeCallbackType =
53    extern "C" fn(RefAny, CallbackInfo, RadioGroupState) -> Update;
54impl_widget_callback!(
55    RadioGroupOnChange,
56    OptionRadioGroupOnChange,
57    RadioGroupOnChangeCallback,
58    RadioGroupOnChangeCallbackType
59);
60
61azul_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)]
77pub 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)]
87pub 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)]
100pub struct RadioGroupState {
101    /// Zero-based index of the selected option.
102    pub selected_index: usize,
103}
104
105// ---- dimensions (logical px) ----
106const CIRCLE_SIZE: isize = 16;
107const CIRCLE_RADIUS: isize = 8;
108const CIRCLE_BORDER: isize = 1;
109const DOT_SIZE: isize = 8;
110const DOT_RADIUS: isize = 4;
111/// Gap between stacked rows (vertical) / between side-by-side rows (horizontal).
112const ROW_GAP: isize = 6;
113/// Gap between the indicator circle and its label.
114const LABEL_GAP: isize = 8;
115
116// ---- colours ----
117/// Indicator ring colour (#9b9b9b).
118const 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).
125const DOT_COLOR: ColorU = ColorU {
126    r: 13,
127    g: 110,
128    b: 253,
129    a: 255,
130};
131
132const DOT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(DOT_COLOR)];
133const 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.
137static 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).
206static 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).
227static 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.
249fn build_container_style(horizontal: bool) -> CssPropertyWithConditionsVec {
250    let direction = if horizontal {
251        LayoutFlexDirection::Row
252    } else {
253        LayoutFlexDirection::Column
254    };
255    CssPropertyWithConditionsVec::from_vec(alloc::vec![
256        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
257        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(direction)),
258        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
259        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
260            0,
261        ))),
262    ])
263}
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).
267fn build_row_style(horizontal: bool) -> CssPropertyWithConditionsVec {
268    let mut v: Vec<CssPropertyWithConditions> = alloc::vec![
269        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
270        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
271            LayoutFlexDirection::Row,
272        )),
273        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
274        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
275            0,
276        ))),
277        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
278        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
279    ];
280    if horizontal {
281        v.push(CssPropertyWithConditions::simple(
282            CssProperty::const_margin_right(LayoutMarginRight::const_px(ROW_GAP * 2)),
283        ));
284    } else {
285        v.push(CssPropertyWithConditions::simple(
286            CssProperty::const_margin_bottom(LayoutMarginBottom::const_px(ROW_GAP)),
287        ));
288    }
289    CssPropertyWithConditionsVec::from_vec(v)
290}
291
292/// The label-text style: a small left gap from the indicator.
293static 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
301impl RadioGroup {
302    /// Creates a radio group from the given options, with the first one selected.
303    #[must_use] pub fn create(options: StringVec) -> Self {
304        Self {
305            radio_group_state: RadioGroupStateWrapper {
306                inner: RadioGroupState { selected_index: 0 },
307                horizontal: false,
308                ..Default::default()
309            },
310            options,
311            container_style: build_container_style(false),
312        }
313    }
314
315    /// Sets the currently selected option index.
316    #[inline]
317    pub const fn set_selected_index(&mut self, selected_index: usize) {
318        self.radio_group_state.inner.selected_index = selected_index;
319    }
320
321    /// Builder-style setter for the selected option index.
322    #[inline]
323    #[must_use] pub const fn with_selected_index(mut self, selected_index: usize) -> Self {
324        self.set_selected_index(selected_index);
325        self
326    }
327
328    /// Lays the options out horizontally (default is vertical).
329    #[inline]
330    pub fn set_horizontal(&mut self, horizontal: bool) {
331        self.radio_group_state.horizontal = horizontal;
332        self.container_style = build_container_style(horizontal);
333    }
334
335    /// Builder-style setter for the horizontal layout flag.
336    #[inline]
337    #[must_use] pub fn with_horizontal(mut self, horizontal: bool) -> Self {
338        self.set_horizontal(horizontal);
339        self
340    }
341
342    #[inline]
343    #[must_use] pub fn swap_with_default(&mut self) -> Self {
344        let mut s = Self::create(StringVec::from_const_slice(&[]));
345        core::mem::swap(&mut s, self);
346        s
347    }
348
349    #[inline]
350    pub fn set_on_change<C: Into<RadioGroupOnChangeCallback>>(
351        &mut self,
352        data: RefAny,
353        on_change: C,
354    ) {
355        self.radio_group_state.on_change = Some(RadioGroupOnChange {
356            callback: on_change.into(),
357            refany: data,
358        })
359        .into();
360    }
361
362    #[inline]
363    #[must_use] pub fn with_on_change<C: Into<RadioGroupOnChangeCallback>>(
364        mut self,
365        data: RefAny,
366        on_change: C,
367    ) -> Self {
368        self.set_on_change(data, on_change);
369        self
370    }
371
372    #[must_use] pub fn dom(self) -> Dom {
373        use azul_core::{
374            callbacks::CoreCallback,
375            dom::{EventFilter, HoverEventFilter},
376            refany::OptionRefAny,
377        };
378
379        let selected = self.radio_group_state.inner.selected_index;
380        let horizontal = self.radio_group_state.horizontal;
381        let count = self.options.as_ref().len();
382
383        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        let state = RefAny::new(self.radio_group_state);
388
389        let mut children: Vec<Dom> = Vec::with_capacity(count);
390        for (i, label) in self.options.as_ref().iter().enumerate() {
391            let dot_style = if i == selected {
392                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_SELECTED)
393            } else {
394                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_UNSELECTED)
395            };
396
397            let circle = Dom::create_div()
398                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CIRCLE_CLASS))
399                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
400                    RADIO_GROUP_CIRCLE_STYLE,
401                ))
402                .with_children(
403                    vec![Dom::create_div()
404                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
405                            RADIO_GROUP_DOT_CLASS,
406                        ))
407                        .with_css_props(dot_style)]
408                    .into(),
409                );
410
411            let label_node = Dom::create_text(label.clone())
412                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_LABEL_CLASS))
413                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
414                    RADIO_GROUP_LABEL_STYLE,
415                ));
416
417            children.push(
418                Dom::create_div()
419                    .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_ROW_CLASS))
420                    .with_css_props(row_style.clone())
421                    .with_callbacks(
422                        vec![CoreCallbackData {
423                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
424                            callback: CoreCallback {
425                                cb: on_radio_row_click as usize,
426                                ctx: OptionRefAny::None,
427                            },
428                            refany: state.clone(),
429                        }]
430                        .into(),
431                    )
432                    .with_tab_index(TabIndex::Auto)
433                    .with_children(vec![circle, label_node].into()),
434            );
435        }
436
437        Dom::create_div()
438            .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CLASS))
439            .with_css_props(self.container_style)
440            .with_children(children.into())
441    }
442}
443
444impl Default for RadioGroup {
445    fn default() -> Self {
446        Self::create(StringVec::from_const_slice(&[]))
447    }
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.
455extern "C" fn on_radio_row_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
456    use azul_core::dom::DomNodeId;
457
458    let clicked = info.get_hit_node();
459    let Some(parent) = info.get_parent(clicked) else {
460        return Update::DoNothing;
461    };
462
463    // Collect the option rows in document order.
464    let mut rows: Vec<DomNodeId> = Vec::new();
465    let mut cur = info.get_first_child(parent);
466    while let Some(node) = cur {
467        rows.push(node);
468        cur = info.get_next_sibling(node);
469    }
470
471    let Some(selected) = rows.iter().position(|n| *n == clicked) else {
472        return Update::DoNothing;
473    };
474
475    let result = {
476        let Some(mut rg) = data.downcast_mut::<RadioGroupStateWrapper>() else {
477            return Update::DoNothing;
478        };
479        rg.inner.selected_index = selected;
480        let inner = rg.inner;
481        let rg = &mut *rg;
482        match rg.on_change.as_mut() {
483            Some(RadioGroupOnChange { callback, refany }) => {
484                (callback.cb)(refany.clone(), info, inner)
485            }
486            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    for (i, row) in rows.iter().enumerate() {
494        let Some(circle) = info.get_first_child(*row) else {
495            continue;
496        };
497        let Some(dot) = info.get_first_child(circle) else {
498            continue;
499        };
500        let opacity = if i == selected { 100 } else { 0 };
501        info.set_css_property(dot, CssProperty::const_opacity(StyleOpacity::const_new(opacity)));
502    }
503
504    result
505}
506
507impl 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)]
520mod 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                IdOrClass::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 `NodeType::Text` node (`None` for any other node type).
723    fn text_of(node: &Dom) -> Option<&str> {
724        match node.root.get_node_type() {
725            NodeType::Text(s) => Some(s.as_ref().as_str()),
726            _ => None,
727        }
728    }
729
730    fn row_of(dom: &Dom, i: usize) -> &Dom {
731        &dom.children.as_ref()[i]
732    }
733
734    /// `row → circle (first child) → dot (first child)` — the path the click
735    /// handler itself walks.
736    fn dot_of(dom: &Dom, i: usize) -> &Dom {
737        &row_of(dom, i).children.as_ref()[0].children.as_ref()[0]
738    }
739
740    fn label_of(dom: &Dom, i: usize) -> &Dom {
741        &row_of(dom, i).children.as_ref()[1]
742    }
743
744    /// The `RefAny` row `i`'s click callback carries.
745    fn row_state(dom: &Dom, i: usize) -> RefAny {
746        row_of(dom, i)
747            .root
748            .get_callbacks()
749            .as_ref()
750            .first()
751            .expect("every option row must carry the click callback")
752            .refany
753            .clone()
754    }
755
756    // ------------------------------------------------------------------
757    // Callback harness
758    // ------------------------------------------------------------------
759
760    /// Flattened (pre-order) node id of option row `i`: the tree is
761    /// `root, [row, circle, dot, label] * n`.
762    fn row_node(i: usize) -> DomNodeId {
763        node(1 + 4 * i)
764    }
765
766    /// Flattened node id of option `i`'s indicator dot.
767    fn dot_node(i: usize) -> NodeId {
768        NodeId::new(3 + 4 * i)
769    }
770
771    fn node(idx: usize) -> DomNodeId {
772        DomNodeId {
773            dom: DomId::ROOT_ID,
774            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
775        }
776    }
777
778    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
779    /// hit" case. `CallbackInfo::set_css_property` *panics* on such an id, so the
780    /// handler must bail out long before reaching it.
781    fn node_none() -> DomNodeId {
782        DomNodeId {
783            dom: DomId::ROOT_ID,
784            node: NodeHierarchyItemId::NONE,
785        }
786    }
787
788    /// A `DomLayoutResult` with an *empty* layout tree: `on_radio_row_click` only
789    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
790    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
791        DomLayoutResult {
792            styled_dom,
793            layout_tree: LayoutTree {
794                nodes: Vec::new(),
795                warm: Vec::new(),
796                cold: Vec::new(),
797                root: 0,
798                dom_to_layout: BTreeMap::new(),
799                children_arena: Vec::new(),
800                children_offsets: Vec::new(),
801                subtree_needs_intrinsic: Vec::new(),
802            },
803            calculated_positions: Vec::new(),
804            viewport: LogicalRect::zero(),
805            display_list: DisplayList::default(),
806            scroll_ids: HashMap::new(),
807            scroll_id_to_node_id: HashMap::new(),
808        }
809    }
810
811    /// Renders `rg`, then hands back both the flattened DOM *and* the very
812    /// `RefAny` the widget registered on row 0's mouse-up callback. Driving the
813    /// handler with these two is the real wiring — nothing is re-created by hand,
814    /// so a mismatch between what `dom()` stores and what the handler expects
815    /// cannot hide behind the fixture. Requires at least one option.
816    fn flatten(rg: RadioGroup) -> (StyledDom, RefAny) {
817        let dom = rg.dom();
818        let state = row_state(&dom, 0);
819        (StyledDom::create_from_dom(dom), state)
820    }
821
822    /// Invokes `on_radio_row_click` against a `LayoutWindow` holding `styled` (or
823    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
824    /// Returns the `Update` plus every recorded `CallbackChange`.
825    fn run_click(
826        styled: Option<StyledDom>,
827        hit: DomNodeId,
828        data: RefAny,
829    ) -> (Update, Vec<CallbackChange>) {
830        let mut layout_window =
831            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
832        if let Some(sd) = styled {
833            layout_window
834                .layout_results
835                .insert(DomId::ROOT_ID, layout_result(sd));
836        }
837
838        let renderer_resources = RendererResources::default();
839        let previous_window_state: Option<FullWindowState> = None;
840        let current_window_state = FullWindowState::default();
841        let gl_context = OptionGlContextPtr::None;
842        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
843            BTreeMap::new();
844        let window_handle = RawWindowHandle::Unsupported;
845        let system_callbacks = ExternalSystemCallbacks::rust_internal();
846
847        let ref_data = CallbackInfoRefData {
848            layout_window: &layout_window,
849            renderer_resources: &renderer_resources,
850            previous_window_state: &previous_window_state,
851            current_window_state: &current_window_state,
852            gl_context: &gl_context,
853            current_scroll_manager: &scroll_states,
854            current_window_handle: &window_handle,
855            system_callbacks: &system_callbacks,
856            system_style: Arc::new(SystemStyle::default()),
857            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
858            #[cfg(feature = "icu")]
859            icu_localizer: IcuLocalizerHandle::default(),
860            ctx: OptionRefAny::None,
861        };
862
863        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
864
865        let info = CallbackInfo::new(
866            &ref_data,
867            &changes,
868            hit,
869            OptionLogicalPosition::None,
870            OptionLogicalPosition::None,
871        );
872
873        let update = on_radio_row_click(data, info);
874        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
875        (update, recorded)
876    }
877
878    /// The opacity overrides pushed onto individual nodes, in push order.
879    fn pushed_opacities(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
880        changes
881            .iter()
882            .filter_map(|c| match c {
883                CallbackChange::ChangeNodeCssProperties {
884                    node_id, properties, ..
885                } => {
886                    let o = properties.as_ref().iter().find_map(|p| match p {
887                        CssProperty::Opacity(o) => o.get_property().map(|o| o.inner.normalized()),
888                        _ => None,
889                    })?;
890                    Some((*node_id, o))
891                }
892                _ => None,
893            })
894            .collect()
895    }
896
897    /// What a correct restyle of an `n`-option group with option `selected` looks
898    /// like: every dot touched exactly once, only the selected one opaque.
899    fn expected_opacities(n: usize, selected: usize) -> Vec<(NodeId, f32)> {
900        (0..n)
901            .map(|i| (dot_node(i), if i == selected { 1.0 } else { 0.0 }))
902            .collect()
903    }
904
905    fn selected_index_of(data: &mut RefAny) -> usize {
906        data.downcast_ref::<RadioGroupStateWrapper>()
907            .expect("payload must still be a RadioGroupStateWrapper")
908            .inner
909            .selected_index
910    }
911
912    /// A `RefAny` payload recording every index a user `on_change` sees.
913    struct ChangeLog {
914        seen: Vec<usize>,
915    }
916
917    extern "C" fn record_change(
918        mut data: RefAny,
919        _: CallbackInfo,
920        state: RadioGroupState,
921    ) -> Update {
922        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
923            log.seen.push(state.selected_index);
924        }
925        Update::RefreshDom
926    }
927
928    extern "C" fn change_do_nothing(_: RefAny, _: CallbackInfo, _: RadioGroupState) -> Update {
929        Update::DoNothing
930    }
931
932    extern "C" fn change_refresh_all(_: RefAny, _: CallbackInfo, _: RadioGroupState) -> Update {
933        Update::RefreshDomAllWindows
934    }
935
936    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
937    fn change_cb(f: RadioGroupOnChangeCallbackType) -> RadioGroupOnChangeCallback {
938        f.into()
939    }
940
941    fn log_refany() -> RefAny {
942        RefAny::new(ChangeLog { seen: Vec::new() })
943    }
944
945    fn log_indices(data: &mut RefAny) -> Vec<usize> {
946        data.downcast_ref::<ChangeLog>()
947            .expect("payload must still be a ChangeLog")
948            .seen
949            .clone()
950    }
951
952    // ==================================================================
953    // build_container_style
954    // ==================================================================
955
956    #[test]
957    fn container_style_switches_only_the_flex_direction() {
958        // Orientation is the *only* thing this function is allowed to vary; if it
959        // also flipped, say, align-self, a horizontal group would stretch across
960        // the parent while a vertical one hugs its content.
961        let vertical = build_container_style(false);
962        let horizontal = build_container_style(true);
963
964        let v = props(vertical.as_ref());
965        let h = props(horizontal.as_ref());
966        assert_eq!(
967            v.len(),
968            h.len(),
969            "the two orientations declare a different number of properties",
970        );
971
972        let differing: Vec<_> = v
973            .iter()
974            .zip(h.iter())
975            .filter(|(a, b)| a != b)
976            .map(|(a, _)| discriminant(a))
977            .collect();
978        assert_eq!(
979            differing,
980            vec![discriminant(&CssProperty::const_flex_direction(
981                LayoutFlexDirection::Row
982            ))],
983            "the vertical/horizontal container styles differ in more than the flex direction",
984        );
985
986        assert_eq!(
987            flex_direction(&v),
988            Some(LayoutFlexDirection::Column),
989            "a vertical radio group must stack its options",
990        );
991        assert_eq!(
992            flex_direction(&h),
993            Some(LayoutFlexDirection::Row),
994            "a horizontal radio group must lay its options side by side",
995        );
996    }
997
998    #[test]
999    fn container_style_is_pure_unconditional_and_declares_nothing_twice() {
1000        for horizontal in [false, true] {
1001            let a = build_container_style(horizontal);
1002            let b = build_container_style(horizontal);
1003            assert_eq!(
1004                a.as_ref(),
1005                b.as_ref(),
1006                "build_container_style({horizontal}) is not a pure function",
1007            );
1008            no_duplicate_properties("the container style", a.as_ref());
1009            assert!(
1010                all_unconditional(a.as_ref()),
1011                "the container style must apply unconditionally",
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn container_style_is_a_non_growing_flex_box_in_both_orientations() {
1018        // `flex-grow: 0` + `align-self: start` is what keeps the group hugging its
1019        // options instead of being stretched by the parent flex line.
1020        for horizontal in [false, true] {
1021            let style = build_container_style(horizontal);
1022            assert!(
1023                has_property(
1024                    style.as_ref(),
1025                    &CssProperty::const_display(LayoutDisplay::Flex)
1026                ),
1027                "horizontal={horizontal}: the container is not a flex box",
1028            );
1029            assert!(
1030                has_property(
1031                    style.as_ref(),
1032                    &CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
1033                ),
1034                "horizontal={horizontal}: the container would be stretched by its parent",
1035            );
1036            assert!(
1037                has_property(
1038                    style.as_ref(),
1039                    &CssProperty::align_self(LayoutAlignSelf::Start)
1040                ),
1041                "horizontal={horizontal}: the container lost its align-self:start",
1042            );
1043        }
1044    }
1045
1046    // ==================================================================
1047    // build_row_style
1048    // ==================================================================
1049
1050    #[test]
1051    fn row_style_puts_the_inter_row_gap_on_the_stacking_axis() {
1052        // Vertical groups stack downwards -> the gap belongs on the bottom;
1053        // horizontal groups run rightwards -> it belongs on the right. Putting it
1054        // on the wrong axis leaves the options touching along the axis they are
1055        // actually laid out on.
1056        let vertical = props(build_row_style(false).as_ref());
1057        assert_eq!(
1058            margin_bottom(&vertical).map(px),
1059            Some(ROW_GAP as f32),
1060            "a vertically stacked row must separate itself from the next one",
1061        );
1062        assert_eq!(
1063            margin_right(&vertical),
1064            None,
1065            "a vertically stacked row must not push its neighbours sideways",
1066        );
1067
1068        let horizontal = props(build_row_style(true).as_ref());
1069        assert_eq!(
1070            margin_right(&horizontal).map(px),
1071            Some((ROW_GAP * 2) as f32),
1072            "a horizontal row must separate itself from the next one",
1073        );
1074        assert_eq!(
1075            margin_bottom(&horizontal),
1076            None,
1077            "a horizontal row must not add vertical spacing",
1078        );
1079    }
1080
1081    #[test]
1082    fn row_style_is_always_an_inner_row_regardless_of_the_group_orientation() {
1083        // The *group* orientation must not leak into the row: a row is always
1084        // `circle | label` left-to-right, even inside a column group. A naive
1085        // "pass horizontal through" would render the label under the indicator.
1086        for horizontal in [false, true] {
1087            let style = props(build_row_style(horizontal).as_ref());
1088            assert_eq!(
1089                flex_direction(&style),
1090                Some(LayoutFlexDirection::Row),
1091                "horizontal={horizontal}: the indicator/label pair is not laid out in a row",
1092            );
1093            assert!(
1094                has_property(
1095                    build_row_style(horizontal).as_ref(),
1096                    &CssProperty::const_align_items(LayoutAlignItems::Center)
1097                ),
1098                "horizontal={horizontal}: the label is not vertically centred on the indicator",
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn row_style_marks_the_whole_row_as_a_click_target() {
1105        // The row is what carries the mouse-up handler, so it must *look*
1106        // clickable and must not start a text selection when dragged.
1107        for horizontal in [false, true] {
1108            let style = props(build_row_style(horizontal).as_ref());
1109            assert_eq!(
1110                cursor(&style),
1111                Some(StyleCursor::Pointer),
1112                "horizontal={horizontal}: the row does not look clickable",
1113            );
1114            assert_eq!(
1115                user_select(&style),
1116                Some(StyleUserSelect::None),
1117                "horizontal={horizontal}: dragging a row would select its label text",
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn row_style_is_pure_unconditional_and_declares_nothing_twice() {
1124        for horizontal in [false, true] {
1125            let a = build_row_style(horizontal);
1126            let b = build_row_style(horizontal);
1127            assert_eq!(
1128                a.as_ref(),
1129                b.as_ref(),
1130                "build_row_style({horizontal}) is not a pure function",
1131            );
1132            no_duplicate_properties("the row style", a.as_ref());
1133            assert!(
1134                all_unconditional(a.as_ref()),
1135                "the row style must apply unconditionally",
1136            );
1137        }
1138    }
1139
1140    // ==================================================================
1141    // The const style tables
1142    // ==================================================================
1143
1144    #[test]
1145    fn the_const_style_tables_declare_nothing_twice_and_apply_unconditionally() {
1146        for (name, style) in [
1147            ("the circle style", RADIO_GROUP_CIRCLE_STYLE),
1148            ("the selected dot style", RADIO_GROUP_DOT_STYLE_SELECTED),
1149            ("the unselected dot style", RADIO_GROUP_DOT_STYLE_UNSELECTED),
1150            ("the label style", RADIO_GROUP_LABEL_STYLE),
1151        ] {
1152            no_duplicate_properties(name, style);
1153            assert!(all_unconditional(style), "{name} must apply unconditionally");
1154        }
1155    }
1156
1157    #[test]
1158    fn the_two_dot_styles_differ_in_opacity_and_nothing_else() {
1159        // Opacity is the *only* thing that may distinguish a selected option from
1160        // an unselected one: a size or colour difference would reflow (or recolour)
1161        // the row as the selection moves.
1162        let selected = props(RADIO_GROUP_DOT_STYLE_SELECTED);
1163        let unselected = props(RADIO_GROUP_DOT_STYLE_UNSELECTED);
1164        assert_eq!(
1165            selected.len(),
1166            unselected.len(),
1167            "the two dot styles declare a different number of properties",
1168        );
1169
1170        let differing: Vec<_> = selected
1171            .iter()
1172            .zip(unselected.iter())
1173            .filter(|(a, b)| a != b)
1174            .map(|(a, _)| discriminant(a))
1175            .collect();
1176        assert_eq!(
1177            differing,
1178            vec![discriminant(&CssProperty::const_opacity(
1179                StyleOpacity::const_new(0)
1180            ))],
1181            "the selected/unselected dot styles differ in something other than opacity",
1182        );
1183
1184        assert_eq!(
1185            opacity_of(&selected),
1186            Some(1.0),
1187            "the selected dot is not fully opaque (const_new takes a *percentage*)",
1188        );
1189        assert_eq!(
1190            opacity_of(&unselected),
1191            Some(0.0),
1192            "the unselected dot is still visible",
1193        );
1194    }
1195
1196    #[test]
1197    fn the_indicator_geometry_is_absolute_px_and_actually_circular() {
1198        // `border-radius = size / 2` on all four corners is what makes the ring and
1199        // the dot circles rather than rounded squares; and the dot plus the ring's
1200        // two borders must fit inside the ring.
1201        assert_eq!(CIRCLE_RADIUS * 2, CIRCLE_SIZE, "the ring is not a circle");
1202        assert_eq!(DOT_RADIUS * 2, DOT_SIZE, "the dot is not a circle");
1203        assert!(
1204            DOT_SIZE + 2 * CIRCLE_BORDER <= CIRCLE_SIZE,
1205            "the dot ({DOT_SIZE}px) does not fit inside the ring ({CIRCLE_SIZE}px + \
1206             {CIRCLE_BORDER}px borders)",
1207        );
1208
1209        let circle = props(RADIO_GROUP_CIRCLE_STYLE);
1210        assert_eq!(width(&circle).map(px), Some(CIRCLE_SIZE as f32));
1211        assert_eq!(height(&circle).map(px), Some(CIRCLE_SIZE as f32));
1212        assert_eq!(
1213            border_top_left_radius(&circle).map(px),
1214            Some(CIRCLE_RADIUS as f32),
1215        );
1216
1217        for style in [RADIO_GROUP_DOT_STYLE_SELECTED, RADIO_GROUP_DOT_STYLE_UNSELECTED] {
1218            let dot = props(style);
1219            assert_eq!(width(&dot).map(px), Some(DOT_SIZE as f32));
1220            assert_eq!(height(&dot).map(px), Some(DOT_SIZE as f32));
1221            assert_eq!(
1222                border_top_left_radius(&dot).map(px),
1223                Some(DOT_RADIUS as f32),
1224            );
1225        }
1226
1227        assert_eq!(
1228            margin_left(&props(RADIO_GROUP_LABEL_STYLE)).map(px),
1229            Some(LABEL_GAP as f32),
1230            "the label lost its gap from the indicator",
1231        );
1232    }
1233
1234    // ==================================================================
1235    // RadioGroup::create
1236    // ==================================================================
1237
1238    #[test]
1239    fn create_preserves_the_options_verbatim_and_defaults_the_state() {
1240        for case in [
1241            vec![],
1242            vec!["only"],
1243            vec!["a", "b"],
1244            vec!["dup", "dup", "dup"],
1245            vec!["Yes", "No", "Maybe", "Ask again later"],
1246        ] {
1247            let rg = group(&case);
1248
1249            let got: Vec<&str> = rg.options.as_ref().iter().map(AzString::as_str).collect();
1250            assert_eq!(got, case, "create must not reorder/drop/rewrite options");
1251            assert_eq!(
1252                rg.radio_group_state.inner.selected_index, 0,
1253                "a fresh radio group selects its first option",
1254            );
1255            assert!(
1256                !rg.radio_group_state.horizontal,
1257                "a fresh radio group is vertical",
1258            );
1259            assert!(
1260                rg.radio_group_state.on_change.as_ref().is_none(),
1261                "create must not invent a callback",
1262            );
1263            assert_eq!(
1264                rg.container_style.as_ref(),
1265                build_container_style(false).as_ref(),
1266                "create must build the *vertical* container style",
1267            );
1268        }
1269    }
1270
1271    #[test]
1272    fn create_survives_pathological_labels() {
1273        // empty string, whitespace-only, emoji + ZWJ, RTL, stacked combining marks,
1274        // an embedded NUL, invisible formatting chars, and a 100k-char label.
1275        let huge = "x".repeat(100_000);
1276        let case = vec![
1277            "",
1278            "   ",
1279            "a\u{0}b",
1280            "👨‍👩‍👧‍👦",
1281            "مرحبا",
1282            "e\u{0301}\u{0301}\u{0301}",
1283            "\u{200b}\u{feff}",
1284            huge.as_str(),
1285        ];
1286        let rg = group(&case);
1287
1288        let got: Vec<&str> = rg.options.as_ref().iter().map(AzString::as_str).collect();
1289        assert_eq!(got, case, "options must survive byte-for-byte");
1290        assert_eq!(rg.options.as_ref()[7].as_str().len(), 100_000);
1291
1292        // … and they must survive the trip through the DOM unchanged.
1293        let dom = rg.dom();
1294        let texts: Vec<&str> = (0..case.len()).filter_map(|i| text_of(label_of(&dom, i))).collect();
1295        assert_eq!(texts, case, "a label was mangled on its way into the DOM");
1296    }
1297
1298    #[test]
1299    fn create_with_a_huge_option_list_does_not_panic() {
1300        let n = 10_000;
1301        let rg = RadioGroup::create(n_labels(n));
1302        assert_eq!(rg.options.as_ref().len(), n);
1303        assert_eq!(rg.options.as_ref()[n - 1].as_str(), "o9999");
1304    }
1305
1306    #[test]
1307    fn default_equals_create_with_no_options() {
1308        assert_eq!(
1309            RadioGroup::default(),
1310            RadioGroup::create(StringVec::from_const_slice(&[])),
1311        );
1312    }
1313
1314    // ==================================================================
1315    // set_selected_index / with_selected_index
1316    // ==================================================================
1317
1318    #[test]
1319    fn selected_index_is_stored_verbatim_at_every_boundary() {
1320        // The setter is documented as a plain store — no clamping to the option
1321        // count — so the extremes must round-trip exactly rather than saturate,
1322        // wrap, or panic in a debug build.
1323        for idx in [0, 1, 2, 3, 1_000, usize::MAX - 1, usize::MAX] {
1324            let mut rg = group(&["a", "b", "c"]);
1325            rg.set_selected_index(idx);
1326            assert_eq!(
1327                rg.radio_group_state.inner.selected_index, idx,
1328                "set_selected_index({idx}) did not store what it was given",
1329            );
1330
1331            let built = group(&["a", "b", "c"]).with_selected_index(idx);
1332            assert_eq!(
1333                built, rg,
1334                "with_selected_index({idx}) disagrees with the mutating setter",
1335            );
1336        }
1337    }
1338
1339    #[test]
1340    fn setting_the_index_repeatedly_keeps_only_the_last_value() {
1341        let mut rg = group(&["a", "b"]);
1342        for idx in [1, 0, usize::MAX, 1, 0] {
1343            rg.set_selected_index(idx);
1344        }
1345        assert_eq!(rg.radio_group_state.inner.selected_index, 0);
1346    }
1347
1348    #[test]
1349    fn with_selected_index_touches_nothing_but_the_index() {
1350        let before = group(&["a", "b", "c"]).with_horizontal(true);
1351        let after = before.clone().with_selected_index(2);
1352
1353        assert_eq!(after.options.as_ref(), before.options.as_ref());
1354        assert_eq!(after.container_style.as_ref(), before.container_style.as_ref());
1355        assert_eq!(
1356            after.radio_group_state.horizontal,
1357            before.radio_group_state.horizontal,
1358            "changing the selection must not change the layout direction",
1359        );
1360        assert_eq!(after.radio_group_state.inner.selected_index, 2);
1361    }
1362
1363    #[test]
1364    fn an_out_of_range_selection_renders_every_dot_hidden() {
1365        // Nothing clamps `selected_index`, so `dom()` has to cope with an index no
1366        // option owns: it must render the full option list with *no* dot lit
1367        // rather than panicking or highlighting a wrapped-around row.
1368        for idx in [3, 4, 1_000, usize::MAX - 1, usize::MAX] {
1369            let dom = group(&["a", "b", "c"]).with_selected_index(idx).dom();
1370            assert_eq!(
1371                dom.children.as_ref().len(),
1372                3,
1373                "idx={idx}: an out-of-range selection changed the option count",
1374            );
1375            for i in 0..3 {
1376                assert_eq!(
1377                    opacity_of(&inline_props(dot_of(&dom, i))),
1378                    Some(0.0),
1379                    "idx={idx}: option {i} is lit even though nothing is selected",
1380                );
1381            }
1382        }
1383    }
1384
1385    #[test]
1386    fn selecting_an_option_lights_exactly_that_one() {
1387        for selected in 0..4 {
1388            let dom = group(&["a", "b", "c", "d"])
1389                .with_selected_index(selected)
1390                .dom();
1391            let lit: Vec<usize> = (0..4)
1392                .filter(|i| opacity_of(&inline_props(dot_of(&dom, *i))) == Some(1.0))
1393                .collect();
1394            assert_eq!(
1395                lit,
1396                vec![selected],
1397                "selecting option {selected} lit {lit:?} instead",
1398            );
1399        }
1400    }
1401
1402    // ==================================================================
1403    // set_horizontal / with_horizontal
1404    // ==================================================================
1405
1406    #[test]
1407    fn the_horizontal_flag_and_the_container_style_never_disagree() {
1408        // Two sources of truth for one fact: the flag drives the *rendered* row
1409        // style, the style drives the container. If the setter updated only one of
1410        // them, a group would stack vertically while spacing itself horizontally.
1411        for horizontal in [false, true] {
1412            let mut rg = group(&["a", "b"]);
1413            rg.set_horizontal(horizontal);
1414            assert_eq!(rg.radio_group_state.horizontal, horizontal);
1415            assert_eq!(
1416                rg.container_style.as_ref(),
1417                build_container_style(horizontal).as_ref(),
1418                "horizontal={horizontal}: the container style was not rebuilt",
1419            );
1420
1421            assert_eq!(
1422                group(&["a", "b"]).with_horizontal(horizontal),
1423                rg,
1424                "with_horizontal({horizontal}) disagrees with the mutating setter",
1425            );
1426        }
1427    }
1428
1429    #[test]
1430    fn toggling_the_orientation_never_accumulates_properties() {
1431        // The style is *rebuilt*, not appended to: flipping the flag a hundred
1432        // times must leave a four-property vec, not a four-hundred-property one
1433        // (where every later duplicate silently overrides the earlier).
1434        let mut rg = group(&["a", "b"]);
1435        let original = rg.clone();
1436        let len = rg.container_style.as_ref().len();
1437
1438        for i in 0..100 {
1439            rg.set_horizontal(i % 2 == 0);
1440            assert_eq!(
1441                rg.container_style.as_ref().len(),
1442                len,
1443                "toggle #{i}: the container style grew",
1444            );
1445        }
1446
1447        rg.set_horizontal(false);
1448        assert_eq!(
1449            rg, original,
1450            "an even number of toggles did not return the group to its original state",
1451        );
1452    }
1453
1454    #[test]
1455    fn the_orientation_reaches_the_rendered_container() {
1456        for (horizontal, expected) in [
1457            (false, LayoutFlexDirection::Column),
1458            (true, LayoutFlexDirection::Row),
1459        ] {
1460            let dom = group(&["a", "b"]).with_horizontal(horizontal).dom();
1461            assert_eq!(
1462                flex_direction(&inline_props(&dom)),
1463                Some(expected),
1464                "horizontal={horizontal}: the rendered container flows the wrong way",
1465            );
1466        }
1467    }
1468
1469    #[test]
1470    fn the_orientation_reaches_the_rendered_rows() {
1471        // `dom()` reads the *flag*, not the container style, to build the row gap —
1472        // so the flag has to be what `set_horizontal` stored.
1473        for horizontal in [false, true] {
1474            let dom = group(&["a", "b"]).with_horizontal(horizontal).dom();
1475            let row = inline_props(row_of(&dom, 0));
1476            assert_eq!(
1477                margin_bottom(&row),
1478                margin_bottom(&props(build_row_style(horizontal).as_ref())),
1479            );
1480            assert_eq!(
1481                margin_right(&row),
1482                margin_right(&props(build_row_style(horizontal).as_ref())),
1483            );
1484        }
1485    }
1486
1487    // ==================================================================
1488    // swap_with_default
1489    // ==================================================================
1490
1491    #[test]
1492    fn swap_with_default_hands_back_the_old_value_and_leaves_a_default_behind() {
1493        let mut rg = group(&["a", "b", "c"])
1494            .with_selected_index(2)
1495            .with_horizontal(true);
1496        let original = rg.clone();
1497
1498        let taken = rg.swap_with_default();
1499
1500        assert_eq!(taken, original, "the caller did not get the old value back");
1501        assert_eq!(
1502            rg,
1503            RadioGroup::default(),
1504            "the widget was not reset to its default",
1505        );
1506        assert!(rg.options.as_ref().is_empty());
1507        assert_eq!(rg.radio_group_state.inner.selected_index, 0);
1508        assert!(!rg.radio_group_state.horizontal);
1509    }
1510
1511    #[test]
1512    fn swapping_twice_restores_the_original() {
1513        let mut rg = group(&["a", "b"]).with_selected_index(1);
1514        let original = rg.clone();
1515
1516        let mut taken = rg.swap_with_default();
1517        let back = taken.swap_with_default();
1518
1519        assert_eq!(back, original, "swap is not its own inverse");
1520        assert_eq!(taken, RadioGroup::default());
1521    }
1522
1523    #[test]
1524    fn swapping_a_default_group_is_a_no_op() {
1525        let mut rg = RadioGroup::default();
1526        let taken = rg.swap_with_default();
1527        assert_eq!(taken, RadioGroup::default());
1528        assert_eq!(rg, RadioGroup::default());
1529    }
1530
1531    #[test]
1532    fn swap_with_default_drops_the_installed_callback_from_the_widget() {
1533        // The callback belongs to the value that was taken, not to the husk left
1534        // behind — otherwise the "default" group would still fire the old handler.
1535        let mut rg = group(&["a"]).with_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
1536        let taken = rg.swap_with_default();
1537
1538        assert!(taken.radio_group_state.on_change.as_ref().is_some());
1539        assert!(
1540            rg.radio_group_state.on_change.as_ref().is_none(),
1541            "the emptied widget kept the old on_change callback",
1542        );
1543    }
1544
1545    // ==================================================================
1546    // set_on_change / with_on_change
1547    // ==================================================================
1548
1549    #[test]
1550    fn with_on_change_installs_the_callback_and_keeps_the_rest_of_the_state() {
1551        let before = group(&["a", "b", "c"])
1552            .with_selected_index(2)
1553            .with_horizontal(true);
1554        // One shared payload: `RefAny` equality is instance identity, so the
1555        // builder/setter comparison below only means something with the same one.
1556        let payload = RefAny::new(7u32);
1557        let after = before
1558            .clone()
1559            .with_on_change(payload.clone(), change_cb(record_change));
1560
1561        assert!(after.radio_group_state.on_change.as_ref().is_some());
1562        assert_eq!(after.options.as_ref(), before.options.as_ref());
1563        assert_eq!(after.container_style.as_ref(), before.container_style.as_ref());
1564        assert_eq!(after.radio_group_state.inner, before.radio_group_state.inner);
1565        assert_eq!(
1566            after.radio_group_state.horizontal,
1567            before.radio_group_state.horizontal,
1568        );
1569
1570        // … and it matches the mutating setter.
1571        let mut mutated = before;
1572        mutated.set_on_change(payload, change_cb(record_change));
1573        assert_eq!(mutated, after);
1574    }
1575
1576    #[test]
1577    fn setting_on_change_twice_replaces_it_rather_than_stacking() {
1578        let mut rg = group(&["a"]);
1579        rg.set_on_change(RefAny::new(1u8), change_cb(record_change));
1580        rg.set_on_change(RefAny::new(2u8), change_cb(change_refresh_all));
1581
1582        let installed = rg
1583            .radio_group_state
1584            .on_change
1585            .as_ref()
1586            .expect("a callback must still be installed");
1587        assert_eq!(
1588            installed.callback,
1589            change_cb(change_refresh_all),
1590            "the first callback survived the second install",
1591        );
1592        let mut payload = installed.refany.clone();
1593        assert_eq!(
1594            *payload.downcast_ref::<u8>().expect("the payload changed type"),
1595            2,
1596            "the first payload survived the second install",
1597        );
1598    }
1599
1600    #[test]
1601    fn installing_a_callback_never_invokes_it() {
1602        // Building a widget is not a user interaction: nothing may fire until a
1603        // click actually happens.
1604        let mut probe = log_refany();
1605        let rg = group(&["a", "b"]).with_on_change(probe.clone(), change_cb(record_change));
1606        let dom = rg.dom();
1607        let _ = StyledDom::create_from_dom(dom);
1608
1609        assert!(
1610            log_indices(&mut probe).is_empty(),
1611            "the on_change callback fired during construction",
1612        );
1613    }
1614
1615    // ==================================================================
1616    // RadioGroup::dom
1617    // ==================================================================
1618
1619    #[test]
1620    fn dom_renders_one_row_per_option_with_the_documented_structure() {
1621        let dom = group(&["a", "b", "c"]).dom();
1622
1623        assert_eq!(classes(&dom), vec!["__azul-native-radio-group"]);
1624        assert_eq!(dom.children.as_ref().len(), 3, "one row per option");
1625
1626        for i in 0..3 {
1627            let row = row_of(&dom, i);
1628            assert_eq!(classes(row), vec!["__azul-native-radio-group-row"]);
1629            assert_eq!(
1630                row.children.as_ref().len(),
1631                2,
1632                "row {i} must be `circle, label`",
1633            );
1634            assert_eq!(
1635                row.root.get_tab_index(),
1636                Some(TabIndex::Auto),
1637                "row {i} is not keyboard reachable",
1638            );
1639
1640            let circle = &row.children.as_ref()[0];
1641            assert_eq!(classes(circle), vec!["__azul-native-radio-group-circle"]);
1642            assert_eq!(circle.children.as_ref().len(), 1, "the circle holds the dot");
1643            assert_eq!(
1644                classes(dot_of(&dom, i)),
1645                vec!["__azul-native-radio-group-dot"],
1646            );
1647            assert_eq!(
1648                classes(label_of(&dom, i)),
1649                vec!["__azul-native-radio-group-label"],
1650            );
1651            assert_eq!(text_of(label_of(&dom, i)), Some(["a", "b", "c"][i]));
1652        }
1653    }
1654
1655    #[test]
1656    fn every_row_carries_exactly_one_mouse_up_handler_pointing_at_the_row_handler() {
1657        let dom = group(&["a", "b", "c"]).dom();
1658
1659        for i in 0..3 {
1660            let cbs = row_of(&dom, i).root.get_callbacks();
1661            assert_eq!(cbs.as_ref().len(), 1, "row {i} must have one callback");
1662            let cb = &cbs.as_ref()[0];
1663            assert_eq!(
1664                cb.event,
1665                EventFilter::Hover(HoverEventFilter::MouseUp),
1666                "row {i} listens for the wrong event",
1667            );
1668            assert_eq!(
1669                cb.callback.cb,
1670                on_radio_row_click as usize,
1671                "row {i} is wired to the wrong handler",
1672            );
1673        }
1674
1675        // The inner nodes must stay inert: a handler on the dot or the label would
1676        // resolve its index against the *wrong* sibling set.
1677        for i in 0..3 {
1678            assert!(row_of(&dom, i).children.as_ref()[0]
1679                .root
1680                .get_callbacks()
1681                .as_ref()
1682                .is_empty());
1683            assert!(dot_of(&dom, i).root.get_callbacks().as_ref().is_empty());
1684            assert!(label_of(&dom, i).root.get_callbacks().as_ref().is_empty());
1685        }
1686    }
1687
1688    #[test]
1689    fn all_rows_share_one_state_refany() {
1690        // Mutual exclusion depends on it: if each row owned its own copy of the
1691        // state, clicking row 2 would leave row 0 still believing it is selected.
1692        let dom = group(&["a", "b", "c", "d"]).dom();
1693        let first = row_state(&dom, 0);
1694        for i in 1..4 {
1695            assert_eq!(
1696                row_state(&dom, i).get_data_ptr(),
1697                first.get_data_ptr(),
1698                "row {i} carries its own state instead of the shared one",
1699            );
1700        }
1701    }
1702
1703    #[test]
1704    fn dom_of_an_empty_group_is_an_empty_container() {
1705        let dom = RadioGroup::default().dom();
1706        assert!(
1707            dom.children.as_ref().is_empty(),
1708            "a group with no options invented a row",
1709        );
1710        assert!(dom.root.get_callbacks().as_ref().is_empty());
1711        assert_eq!(classes(&dom), vec!["__azul-native-radio-group"]);
1712    }
1713
1714    #[test]
1715    fn dom_of_an_empty_group_with_a_selection_does_not_panic() {
1716        // `create` sets index 0 even with zero options, so the "selected option" is
1717        // out of range from the start — the render path must not index into it.
1718        for idx in [0, 1, usize::MAX] {
1719            let dom = RadioGroup::default().with_selected_index(idx).dom();
1720            assert!(dom.children.as_ref().is_empty());
1721        }
1722    }
1723
1724    #[test]
1725    fn dom_flattens_to_four_nodes_per_option() {
1726        // root + (row, circle, dot, label) per option. The click handler's live
1727        // restyle walks exactly this shape, and the callback tests below address
1728        // nodes by this formula.
1729        for n in [0, 1, 2, 7] {
1730            let styled = StyledDom::create_from_dom(RadioGroup::create(n_labels(n)).dom());
1731            assert_eq!(
1732                styled.node_hierarchy.as_ref().len(),
1733                1 + 4 * n,
1734                "an {n}-option group flattened to an unexpected node count",
1735            );
1736        }
1737    }
1738
1739    #[test]
1740    fn a_large_group_renders_without_panicking() {
1741        let n = 500;
1742        let dom = RadioGroup::create(n_labels(n))
1743            .with_selected_index(n - 1)
1744            .dom();
1745        assert_eq!(dom.children.as_ref().len(), n);
1746        assert_eq!(text_of(label_of(&dom, n - 1)), Some("o499"));
1747        assert_eq!(opacity_of(&inline_props(dot_of(&dom, n - 1))), Some(1.0));
1748        assert_eq!(opacity_of(&inline_props(dot_of(&dom, 0))), Some(0.0));
1749    }
1750
1751    // ==================================================================
1752    // on_radio_row_click
1753    // ==================================================================
1754
1755    #[test]
1756    fn clicking_a_row_selects_it_and_restyles_every_dot() {
1757        for clicked in 0..4 {
1758            let (styled, state) = flatten(group(&["a", "b", "c", "d"]));
1759            let mut state_probe = state.clone();
1760
1761            let (update, changes) = run_click(Some(styled), row_node(clicked), state);
1762
1763            assert_eq!(
1764                update,
1765                Update::DoNothing,
1766                "with no on_change installed the handler reports nothing to redraw",
1767            );
1768            assert_eq!(
1769                selected_index_of(&mut state_probe),
1770                clicked,
1771                "clicking row {clicked} selected the wrong option",
1772            );
1773            assert_eq!(
1774                pushed_opacities(&changes),
1775                expected_opacities(4, clicked),
1776                "clicking row {clicked} did not light exactly that row's dot",
1777            );
1778        }
1779    }
1780
1781    #[test]
1782    fn clicking_the_already_selected_row_is_idempotent() {
1783        let (styled, state) = flatten(group(&["a", "b", "c"]).with_selected_index(1));
1784        let mut probe = state.clone();
1785
1786        let (_, changes) = run_click(Some(styled), row_node(1), state);
1787
1788        assert_eq!(selected_index_of(&mut probe), 1);
1789        assert_eq!(
1790            pushed_opacities(&changes),
1791            expected_opacities(3, 1),
1792            "a redundant click must still leave every dot in a consistent state",
1793        );
1794    }
1795
1796    #[test]
1797    fn clicking_repairs_an_out_of_range_selection() {
1798        // The widget can be handed an index no option owns; the first click must
1799        // bring it back into range instead of leaving a group with nothing lit.
1800        let (styled, state) = flatten(group(&["a", "b", "c"]).with_selected_index(usize::MAX));
1801        let mut probe = state.clone();
1802
1803        let (_, changes) = run_click(Some(styled), row_node(2), state);
1804
1805        assert_eq!(selected_index_of(&mut probe), 2);
1806        assert_eq!(pushed_opacities(&changes), expected_opacities(3, 2));
1807    }
1808
1809    #[test]
1810    fn clicking_a_single_option_group_selects_option_zero() {
1811        let (styled, state) = flatten(group(&["only"]));
1812        let mut probe = state.clone();
1813
1814        let (update, changes) = run_click(Some(styled), row_node(0), state);
1815
1816        assert_eq!(update, Update::DoNothing);
1817        assert_eq!(selected_index_of(&mut probe), 0);
1818        assert_eq!(pushed_opacities(&changes), vec![(dot_node(0), 1.0)]);
1819    }
1820
1821    #[test]
1822    fn the_reported_index_always_addresses_a_real_option() {
1823        let n = 32;
1824        for clicked in [0, 1, n / 2, n - 2, n - 1] {
1825            let (styled, state) = flatten(RadioGroup::create(n_labels(n)));
1826            let mut probe = state.clone();
1827
1828            let (_, changes) = run_click(Some(styled), row_node(clicked), state);
1829
1830            let idx = selected_index_of(&mut probe);
1831            assert!(idx < n, "row {clicked} reported out-of-range index {idx}");
1832            assert_eq!(idx, clicked);
1833
1834            let pushed = pushed_opacities(&changes);
1835            assert_eq!(pushed.len(), n, "every dot must be restyled exactly once");
1836            assert_eq!(
1837                pushed.iter().filter(|(_, o)| *o == 1.0).count(),
1838                1,
1839                "exactly one option may be lit at a time",
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn the_user_callback_sees_the_new_index_and_its_update_is_forwarded() {
1846        // Order matters: the selection is written *before* the user callback runs,
1847        // so the callback observes the state the user just asked for.
1848        let mut probe = log_refany();
1849        let rg = group(&["a", "b", "c"]).with_on_change(probe.clone(), change_cb(record_change));
1850        let (styled, state) = flatten(rg);
1851
1852        let (update, changes) = run_click(Some(styled), row_node(2), state.clone());
1853
1854        assert_eq!(log_indices(&mut probe), vec![2], "the callback ran once with the new index");
1855        assert_eq!(update, Update::RefreshDom, "the user's Update was swallowed");
1856        // … and the restyle still happens *after* the user callback returns.
1857        assert_eq!(pushed_opacities(&changes), expected_opacities(3, 2));
1858
1859        // A second click updates the shared state again — the index is not sticky,
1860        // and the user hears about every click, not just the first.
1861        let (styled2, _) = flatten(group(&["a", "b", "c"]));
1862        let (_, _) = run_click(Some(styled2), row_node(0), state.clone());
1863        assert_eq!(log_indices(&mut probe), vec![2, 0]);
1864        let mut state = state;
1865        assert_eq!(
1866            selected_index_of(&mut state),
1867            0,
1868            "the state must hold the *last* clicked index",
1869        );
1870    }
1871
1872    #[test]
1873    fn a_callback_that_declines_the_update_still_gets_the_dots_restyled() {
1874        // A user callback returning DoNothing must not suppress the widget's own
1875        // visual bookkeeping — otherwise the state says "option 1" while option 0
1876        // stays lit.
1877        let rg = group(&["a", "b"]).with_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
1878        let (styled, state) = flatten(rg);
1879        let mut probe = state.clone();
1880
1881        let (update, changes) = run_click(Some(styled), row_node(1), state);
1882
1883        assert_eq!(update, Update::DoNothing);
1884        assert_eq!(selected_index_of(&mut probe), 1);
1885        assert_eq!(
1886            pushed_opacities(&changes),
1887            expected_opacities(2, 1),
1888            "a DoNothing user callback suppressed the dot restyle",
1889        );
1890    }
1891
1892    #[test]
1893    fn every_update_variant_is_propagated_unchanged() {
1894        for (cb, expected) in [
1895            (change_cb(change_do_nothing), Update::DoNothing),
1896            (change_cb(change_refresh_all), Update::RefreshDomAllWindows),
1897            (change_cb(record_change), Update::RefreshDom),
1898        ] {
1899            let rg = group(&["a", "b"]).with_on_change(log_refany(), cb);
1900            let (styled, state) = flatten(rg);
1901            let (update, _) = run_click(Some(styled), row_node(1), state);
1902            assert_eq!(update, expected);
1903        }
1904    }
1905
1906    #[test]
1907    fn clicking_the_root_container_does_nothing() {
1908        // The root has no parent -> the handler must bail before indexing into
1909        // nothing.
1910        let (styled, state) = flatten(group(&["a", "b"]));
1911        let mut probe = state.clone();
1912
1913        let (update, changes) = run_click(Some(styled), node(0), state);
1914
1915        assert_eq!(update, Update::DoNothing);
1916        assert!(changes.is_empty(), "a parentless hit pushed a DOM change");
1917        assert_eq!(selected_index_of(&mut probe), 0, "the state must be untouched");
1918    }
1919
1920    #[test]
1921    fn clicking_a_stale_or_absent_node_does_nothing() {
1922        // Stale hit ids reach callbacks after a DOM mutation, and
1923        // `set_css_property` *panics* on a None node id — so the handler has to
1924        // bail out well before the restyle loop.
1925        for hit in [node(9999), node(usize::MAX - 1), node_none()] {
1926            let (styled, state) = flatten(group(&["a", "b"]).with_selected_index(1));
1927            let mut probe = state.clone();
1928
1929            let (update, changes) = run_click(Some(styled), hit, state);
1930
1931            assert_eq!(update, Update::DoNothing, "{hit:?}: a stale hit was acted on");
1932            assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
1933            assert_eq!(
1934                selected_index_of(&mut probe),
1935                1,
1936                "{hit:?}: a stale hit moved the selection",
1937            );
1938        }
1939    }
1940
1941    #[test]
1942    fn clicking_with_no_layout_result_does_nothing() {
1943        let dom = group(&["a", "b"]).dom();
1944        let state = row_state(&dom, 0);
1945
1946        let (update, changes) = run_click(None, row_node(0), state);
1947
1948        assert_eq!(
1949            update,
1950            Update::DoNothing,
1951            "an empty LayoutWindow must be handled, not unwrapped",
1952        );
1953        assert!(changes.is_empty());
1954    }
1955
1956    #[test]
1957    fn clicking_with_a_foreign_payload_does_nothing_and_leaves_it_intact() {
1958        // The handler downcasts blind; a foreign RefAny must bail out, not
1959        // reinterpret the bytes as a RadioGroupStateWrapper.
1960        let (styled, _) = flatten(group(&["a", "b"]));
1961        let foreign = RefAny::new(0xDEAD_BEEF_u32);
1962
1963        let (update, changes) = run_click(Some(styled), row_node(1), foreign.clone());
1964
1965        assert_eq!(update, Update::DoNothing);
1966        assert!(
1967            changes.is_empty(),
1968            "the handler restyled the DOM through a RefAny it could not read",
1969        );
1970        let mut foreign = foreign;
1971        assert_eq!(
1972            *foreign
1973                .downcast_ref::<u32>()
1974                .expect("the foreign payload was reinterpreted"),
1975            0xDEAD_BEEF,
1976            "the handler corrupted a RefAny it did not understand",
1977        );
1978    }
1979
1980    #[test]
1981    fn clicking_while_the_state_is_already_borrowed_does_nothing() {
1982        let (styled, state) = flatten(group(&["a", "b"]));
1983
1984        // A live mutable borrow on a sibling clone: `downcast_mut` inside the
1985        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
1986        let mut held = state.clone();
1987        let guard = held
1988            .downcast_mut::<RadioGroupStateWrapper>()
1989            .expect("first borrow succeeds");
1990
1991        let (update, changes) = run_click(Some(styled), row_node(1), state);
1992
1993        assert_eq!(update, Update::DoNothing);
1994        assert!(
1995            changes.is_empty(),
1996            "the handler restyled the DOM after failing to update the state",
1997        );
1998        drop(guard);
1999    }
2000
2001    #[test]
2002    fn a_hit_inside_a_row_resolves_against_its_own_siblings() {
2003        // The handler documents `currentTarget` semantics: the hit node is the row
2004        // the callback is registered on, and only rows carry callbacks. Should an
2005        // inner node ever reach it anyway, it must stay memory-safe and push no
2006        // half-finished restyle — the sibling walk simply finds no dots to update.
2007        // (`dot` is its circle's only child -> position 0; `label` is its row's
2008        // second child -> position 1, regardless of which row it belongs to.)
2009        for (hit, expected) in [(node(3), 0usize), (node(11), 0), (node(4), 1), (node(12), 1)] {
2010            let (styled, state) = flatten(group(&["a", "b", "c"]));
2011            let mut probe = state.clone();
2012
2013            let (update, changes) = run_click(Some(styled), hit, state);
2014
2015            assert_eq!(update, Update::DoNothing);
2016            assert_eq!(selected_index_of(&mut probe), expected, "{hit:?}");
2017            assert!(
2018                changes.is_empty(),
2019                "{hit:?}: an inner-node hit pushed a partial restyle",
2020            );
2021        }
2022    }
2023
2024    #[test]
2025    fn many_clicks_keep_the_state_and_the_pushed_opacities_in_agreement() {
2026        // A drift between the stored index and the pushed opacity is exactly the
2027        // class of bug that makes a radio group render a selection it does not
2028        // hold. 60 clicks cycling through a 5-option group.
2029        let (_, state) = flatten(group(&["a", "b", "c", "d", "e"]));
2030
2031        for click in 0..60usize {
2032            let expected = click % 5;
2033            let (styled, _) = flatten(group(&["a", "b", "c", "d", "e"]));
2034            let (_, changes) = run_click(Some(styled), row_node(expected), state.clone());
2035
2036            let mut probe = state.clone();
2037            assert_eq!(
2038                selected_index_of(&mut probe),
2039                expected,
2040                "click #{click}: the stored index drifted",
2041            );
2042            assert_eq!(
2043                pushed_opacities(&changes),
2044                expected_opacities(5, expected),
2045                "click #{click}: the pushed opacities disagree with the stored index",
2046            );
2047        }
2048    }
2049}