1use 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
51pub 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#[derive(Debug, Clone, PartialEq, Eq)]
76#[repr(C)]
77pub struct RadioGroup {
78 pub radio_group_state: RadioGroupStateWrapper,
79 pub options: StringVec,
81 pub container_style: CssPropertyWithConditionsVec,
83}
84
85#[derive(Debug, Default, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct RadioGroupStateWrapper {
88 pub inner: RadioGroupState,
90 pub horizontal: bool,
93 pub on_change: OptionRadioGroupOnChange,
95}
96
97#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
99#[repr(C)]
100pub struct RadioGroupState {
101 pub selected_index: usize,
103}
104
105const CIRCLE_SIZE: isize = 16;
107const CIRCLE_RADIUS: isize = 8;
108const CIRCLE_BORDER: isize = 1;
109const DOT_SIZE: isize = 8;
110const DOT_RADIUS: isize = 4;
111const ROW_GAP: isize = 6;
113const LABEL_GAP: isize = 8;
115
116const CIRCLE_BORDER_COLOR: ColorU = ColorU {
119 r: 155,
120 g: 155,
121 b: 155,
122 a: 255,
123};
124const 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
135static 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
205static 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
226static 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
247fn 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
265fn 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
292static 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 #[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 #[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 #[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 #[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 #[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 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
450extern "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 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 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#[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 fn labels(v: &[&str]) -> StringVec {
558 StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
559 }
560
561 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 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 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 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 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 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 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 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 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 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 fn row_node(i: usize) -> DomNodeId {
763 node(1 + 4 * i)
764 }
765
766 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 fn node_none() -> DomNodeId {
782 DomNodeId {
783 dom: DomId::ROOT_ID,
784 node: NodeHierarchyItemId::NONE,
785 }
786 }
787
788 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 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 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: ¤t_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 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 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 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 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 #[test]
957 fn container_style_switches_only_the_flex_direction() {
958 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 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 #[test]
1051 fn row_style_puts_the_inter_row_gap_on_the_stacking_axis() {
1052 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 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 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 #[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 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 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 #[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 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 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 #[test]
1319 fn selected_index_is_stored_verbatim_at_every_boundary() {
1320 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 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 #[test]
1407 fn the_horizontal_flag_and_the_container_style_never_disagree() {
1408 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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 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 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 #[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 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 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 assert_eq!(pushed_opacities(&changes), expected_opacities(3, 2));
1858
1859 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 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 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 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 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 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 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 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}