Skip to main content

azul_layout/widgets/
segmented.rs

1//! Segmented control / button-group widget — a joined row of mutually-exclusive
2//! buttons where exactly one is selected. A blend of the `tabs::TabHeader` row of
3//! clickable labels and `button.rs`'s styling, with the stateful 3-type split
4//! (state / state-wrapper / widget) of the other interactive widgets.
5//!
6//! Clicking a segment selects it: the internal handler computes the clicked
7//! segment's index from its position among its siblings, updates the
8//! `selected_index`, invokes the user's `on_change(index)`, and live-restyles
9//! every segment (selected vs unselected) via `set_css_property`.
10//!
11//! Key types: [`Segmented`], [`SegmentedState`], [`SegmentedOnChange`].
12
13use std::vec::Vec;
14
15use azul_core::{
16    callbacks::{CoreCallbackData, Update},
17    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
18    refany::RefAny,
19};
20use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
21use azul_css::{
22    props::{
23        basic::{color::ColorU, StyleFontSize},
24        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutJustifyContent, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
25        property::{CssProperty, *},
26        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderRightColor, StyleCursor, StyleTextAlign, StyleUserSelect, StyleTextColor, LayoutBorderLeftWidth, StyleBorderLeftStyle, StyleBorderLeftColor, StyleBorderTopLeftRadius, StyleBorderBottomLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomRightRadius},
27    },
28    impl_option_inner, AzString, StringVec,
29};
30
31use crate::callbacks::{Callback, CallbackInfo};
32
33static SEGMENTED_CLASS: &[IdOrClass] =
34    &[Class(AzString::from_const_str("__azul-native-segmented"))];
35static SEGMENT_ITEM_CLASS: &[IdOrClass] =
36    &[Class(AzString::from_const_str("__azul-native-segmented-item"))];
37
38/// Callback function type invoked when the selected segment changes.
39pub type SegmentedOnChangeCallbackType =
40    extern "C" fn(RefAny, CallbackInfo, SegmentedState) -> Update;
41impl_widget_callback!(
42    SegmentedOnChange,
43    OptionSegmentedOnChange,
44    SegmentedOnChangeCallback,
45    SegmentedOnChangeCallbackType
46);
47
48azul_core::impl_managed_callback! {
49    wrapper:        SegmentedOnChangeCallback,
50    info_ty:        CallbackInfo,
51    return_ty:      Update,
52    default_ret:    Update::DoNothing,
53    invoker_static: SEGMENTED_ON_CHANGE_INVOKER,
54    invoker_ty:     AzSegmentedOnChangeCallbackInvoker,
55    thunk_fn:       az_segmented_on_change_callback_thunk,
56    setter_fn:      AzApp_setSegmentedOnChangeCallbackInvoker,
57    from_handle_fn: AzSegmentedOnChangeCallback_createFromHostHandle,
58    extra_args:     [ state: SegmentedState ],
59}
60
61/// A joined row of mutually-exclusive segments with a selection callback.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[repr(C)]
64pub struct Segmented {
65    pub segmented_state: SegmentedStateWrapper,
66    /// The label of each segment, in order.
67    pub labels: StringVec,
68    /// Style for the row container.
69    pub container_style: CssPropertyWithConditionsVec,
70}
71
72#[derive(Debug, Default, Clone, PartialEq, Eq)]
73#[repr(C)]
74pub struct SegmentedStateWrapper {
75    /// The current selection.
76    pub inner: SegmentedState,
77    /// Optional: function to call when the selection changes.
78    pub on_change: OptionSegmentedOnChange,
79}
80
81/// State of a [`Segmented`]: the index of the currently selected segment.
82#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
83#[repr(C)]
84pub struct SegmentedState {
85    /// Zero-based index of the selected segment.
86    pub selected_index: usize,
87}
88
89// ---- colours ----
90/// Segment border colour (#ced4da).
91const SEG_BORDER_COLOR: ColorU = ColorU {
92    r: 206,
93    g: 212,
94    b: 218,
95    a: 255,
96};
97/// Selected-segment background (#0d6efd, accent blue).
98const SEG_SELECTED_BG_COLOR: ColorU = ColorU {
99    r: 13,
100    g: 110,
101    b: 253,
102    a: 255,
103};
104/// Unselected-segment background (white).
105const SEG_UNSELECTED_BG_COLOR: ColorU = ColorU {
106    r: 255,
107    g: 255,
108    b: 255,
109    a: 255,
110};
111/// Selected-segment text colour (white).
112const SEG_SELECTED_TEXT: ColorU = ColorU {
113    r: 255,
114    g: 255,
115    b: 255,
116    a: 255,
117};
118/// Unselected-segment text colour (#212529, dark).
119const SEG_UNSELECTED_TEXT: ColorU = ColorU {
120    r: 33,
121    g: 37,
122    b: 41,
123    a: 255,
124};
125
126const SEG_SELECTED_BG_ITEMS: &[StyleBackgroundContent] =
127    &[StyleBackgroundContent::Color(SEG_SELECTED_BG_COLOR)];
128const SEG_SELECTED_BG: StyleBackgroundContentVec =
129    StyleBackgroundContentVec::from_const_slice(SEG_SELECTED_BG_ITEMS);
130const SEG_UNSELECTED_BG_ITEMS: &[StyleBackgroundContent] =
131    &[StyleBackgroundContent::Color(SEG_UNSELECTED_BG_COLOR)];
132const SEG_UNSELECTED_BG: StyleBackgroundContentVec =
133    StyleBackgroundContentVec::from_const_slice(SEG_UNSELECTED_BG_ITEMS);
134
135const SEG_RADIUS: isize = 6;
136
137/// Row container: a horizontal flex row that hugs its content.
138static SEGMENTED_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
139    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
140    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
141    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
142    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
143    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
144];
145
146/// Builds the style for one segment. The selected/unselected colours and the
147/// rounding of the outer corners (only the first segment is rounded on the left,
148/// only the last on the right) are the position-dependent properties, so the
149/// style is built at runtime.
150#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
151fn build_segment_style(selected: bool, is_first: bool, is_last: bool) -> CssPropertyWithConditionsVec {
152    let (bg, text) = if selected {
153        (SEG_SELECTED_BG, SEG_SELECTED_TEXT)
154    } else {
155        (SEG_UNSELECTED_BG, SEG_UNSELECTED_TEXT)
156    };
157
158    let mut v: Vec<CssPropertyWithConditions> = alloc::vec![
159        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
160        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
161            LayoutFlexDirection::Row,
162        )),
163        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
164            LayoutJustifyContent::Center,
165        )),
166        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
167        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
168            0,
169        ))),
170        // padding: 6px 12px
171        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
172            6,
173        ))),
174        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
175            LayoutPaddingBottom::const_px(6),
176        )),
177        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
178            LayoutPaddingLeft::const_px(12),
179        )),
180        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
181            LayoutPaddingRight::const_px(12),
182        )),
183        // top/bottom/right borders (the left border is added only for the first segment,
184        // so adjacent segments share a single 1px separator)
185        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
186            LayoutBorderTopWidth::const_px(1),
187        )),
188        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
189            LayoutBorderBottomWidth::const_px(1),
190        )),
191        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
192            LayoutBorderRightWidth::const_px(1),
193        )),
194        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
195            inner: BorderStyle::Solid,
196        })),
197        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
198            StyleBorderBottomStyle {
199                inner: BorderStyle::Solid,
200            },
201        )),
202        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
203            StyleBorderRightStyle {
204                inner: BorderStyle::Solid,
205            },
206        )),
207        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
208            inner: SEG_BORDER_COLOR,
209        })),
210        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
211            StyleBorderBottomColor {
212                inner: SEG_BORDER_COLOR,
213            },
214        )),
215        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
216            StyleBorderRightColor {
217                inner: SEG_BORDER_COLOR,
218            },
219        )),
220        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
221        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
222        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
223        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
224        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
225        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
226            inner: text,
227        })),
228    ];
229
230    if is_first {
231        v.push(CssPropertyWithConditions::simple(
232            CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(1)),
233        ));
234        v.push(CssPropertyWithConditions::simple(
235            CssProperty::const_border_left_style(StyleBorderLeftStyle {
236                inner: BorderStyle::Solid,
237            }),
238        ));
239        v.push(CssPropertyWithConditions::simple(
240            CssProperty::const_border_left_color(StyleBorderLeftColor {
241                inner: SEG_BORDER_COLOR,
242            }),
243        ));
244        v.push(CssPropertyWithConditions::simple(
245            CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(SEG_RADIUS)),
246        ));
247        v.push(CssPropertyWithConditions::simple(
248            CssProperty::const_border_bottom_left_radius(StyleBorderBottomLeftRadius::const_px(
249                SEG_RADIUS,
250            )),
251        ));
252    }
253    if is_last {
254        v.push(CssPropertyWithConditions::simple(
255            CssProperty::const_border_top_right_radius(StyleBorderTopRightRadius::const_px(
256                SEG_RADIUS,
257            )),
258        ));
259        v.push(CssPropertyWithConditions::simple(
260            CssProperty::const_border_bottom_right_radius(StyleBorderBottomRightRadius::const_px(
261                SEG_RADIUS,
262            )),
263        ));
264    }
265
266    CssPropertyWithConditionsVec::from_vec(v)
267}
268
269impl Segmented {
270    /// Creates a segmented control from the given labels, with the first segment selected.
271    #[must_use] pub fn create(labels: StringVec) -> Self {
272        Self {
273            segmented_state: SegmentedStateWrapper {
274                inner: SegmentedState { selected_index: 0 },
275                ..Default::default()
276            },
277            labels,
278            container_style: CssPropertyWithConditionsVec::from_const_slice(
279                SEGMENTED_CONTAINER_STYLE,
280            ),
281        }
282    }
283
284    /// Sets the currently selected segment index.
285    #[inline]
286    pub const fn set_selected_index(&mut self, selected_index: usize) {
287        self.segmented_state.inner.selected_index = selected_index;
288    }
289
290    /// Builder-style setter for the selected segment index.
291    #[inline]
292    #[must_use] pub const fn with_selected_index(mut self, selected_index: usize) -> Self {
293        self.set_selected_index(selected_index);
294        self
295    }
296
297    #[inline]
298    #[must_use] pub fn swap_with_default(&mut self) -> Self {
299        let mut s = Self::create(StringVec::from_const_slice(&[]));
300        core::mem::swap(&mut s, self);
301        s
302    }
303
304    #[inline]
305    pub fn set_on_change<C: Into<SegmentedOnChangeCallback>>(
306        &mut self,
307        data: RefAny,
308        on_change: C,
309    ) {
310        self.segmented_state.on_change = Some(SegmentedOnChange {
311            callback: on_change.into(),
312            refany: data,
313        })
314        .into();
315    }
316
317    #[inline]
318    #[must_use] pub fn with_on_change<C: Into<SegmentedOnChangeCallback>>(
319        mut self,
320        data: RefAny,
321        on_change: C,
322    ) -> Self {
323        self.set_on_change(data, on_change);
324        self
325    }
326
327    #[must_use] pub fn dom(self) -> Dom {
328        use azul_core::{
329            callbacks::CoreCallback,
330            dom::{EventFilter, HoverEventFilter},
331            refany::OptionRefAny,
332        };
333
334        let selected = self.segmented_state.inner.selected_index;
335        let count = self.labels.as_ref().len();
336
337        // One shared RefAny across every segment's callback (RefAny::clone shares
338        // the underlying state — same pattern as tabs/map).
339        let state = RefAny::new(self.segmented_state);
340
341        let mut children: Vec<Dom> = Vec::with_capacity(count);
342        for (i, label) in self.labels.as_ref().iter().enumerate() {
343            let is_first = i == 0;
344            let is_last = i + 1 == count;
345            let seg_style = build_segment_style(i == selected, is_first, is_last);
346
347            children.push(
348                Dom::create_text(label.clone())
349                    .with_ids_and_classes(IdOrClassVec::from_const_slice(SEGMENT_ITEM_CLASS))
350                    .with_css_props(seg_style)
351                    .with_callbacks(
352                        vec![CoreCallbackData {
353                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
354                            callback: CoreCallback {
355                                cb: on_segment_click as usize,
356                                ctx: OptionRefAny::None,
357                            },
358                            refany: state.clone(),
359                        }]
360                        .into(),
361                    )
362                    .with_tab_index(TabIndex::Auto),
363            );
364        }
365
366        Dom::create_div()
367            .with_ids_and_classes(IdOrClassVec::from_const_slice(SEGMENTED_CLASS))
368            .with_css_props(self.container_style)
369            .with_children(children.into())
370    }
371}
372
373impl Default for Segmented {
374    fn default() -> Self {
375        Self::create(StringVec::from_const_slice(&[]))
376    }
377}
378
379/// Click handler shared by all segments. Determines the clicked segment's index
380/// from its position among its siblings, updates the selection, invokes the user
381/// callback, and live-restyles every segment.
382extern "C" fn on_segment_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
383    use azul_core::dom::DomNodeId;
384
385    let clicked = info.get_hit_node();
386    let Some(parent) = info.get_parent(clicked) else {
387        return Update::DoNothing;
388    };
389
390    // Collect the segment siblings in document order.
391    let mut segments: Vec<DomNodeId> = Vec::new();
392    let mut cur = info.get_first_child(parent);
393    while let Some(node) = cur {
394        segments.push(node);
395        cur = info.get_next_sibling(node);
396    }
397
398    let Some(selected) = segments.iter().position(|n| *n == clicked) else {
399        return Update::DoNothing;
400    };
401
402    let result = {
403        let Some(mut seg) = data.downcast_mut::<SegmentedStateWrapper>() else {
404            return Update::DoNothing;
405        };
406        seg.inner.selected_index = selected;
407        let inner = seg.inner;
408        let seg = &mut *seg;
409        match seg.on_change.as_mut() {
410            Some(SegmentedOnChange { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
411            None => Update::DoNothing,
412        }
413    };
414
415    // Live-restyle: selected segment gets the accent fill + light text,
416    // the rest get the neutral fill + dark text.
417    for (i, node) in segments.iter().enumerate() {
418        if i == selected {
419            info.set_css_property(*node, CssProperty::const_background_content(SEG_SELECTED_BG));
420            info.set_css_property(
421                *node,
422                CssProperty::const_text_color(StyleTextColor {
423                    inner: SEG_SELECTED_TEXT,
424                }),
425            );
426        } else {
427            info.set_css_property(
428                *node,
429                CssProperty::const_background_content(SEG_UNSELECTED_BG),
430            );
431            info.set_css_property(
432                *node,
433                CssProperty::const_text_color(StyleTextColor {
434                    inner: SEG_UNSELECTED_TEXT,
435                }),
436            );
437        }
438    }
439
440    result
441}
442
443impl From<Segmented> for Dom {
444    fn from(s: Segmented) -> Self {
445        s.dom()
446    }
447}
448
449#[cfg(test)]
450mod autotest_generated {
451    use std::{
452        collections::{BTreeMap, HashMap, HashSet},
453        sync::{Arc, Mutex},
454    };
455
456    use azul_core::{
457        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
458        geom::{LogicalRect, OptionLogicalPosition},
459        gl::OptionGlContextPtr,
460        hit_test::ScrollPosition,
461        refany::OptionRefAny,
462        resources::RendererResources,
463        styled_dom::{NodeHierarchyItemId, StyledDom},
464        window::{MonitorVec, RawWindowHandle},
465    };
466    use azul_css::{
467        props::basic::{length::SizeMetric, pixel::PixelValue},
468        system::SystemStyle,
469    };
470    use rust_fontconfig::FcFontCache;
471
472    use super::*;
473    #[cfg(feature = "icu")]
474    use crate::icu::IcuLocalizerHandle;
475    use crate::{
476        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
477        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
478        window::{DomLayoutResult, LayoutWindow},
479        window_state::FullWindowState,
480    };
481
482    // ------------------------------------------------------------------
483    // Helpers
484    // ------------------------------------------------------------------
485
486    fn labels(v: &[&str]) -> StringVec {
487        StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
488    }
489
490    /// `n` distinct labels: `s0, s1, … s{n-1}`.
491    fn n_labels(n: usize) -> StringVec {
492        StringVec::from_vec((0..n).map(|i| AzString::from(format!("s{i}"))).collect::<Vec<_>>())
493    }
494
495    /// The eight possible `(selected, is_first, is_last)` argument triples —
496    /// the complete input domain of `build_segment_style`.
497    const ALL_FLAGS: [(bool, bool, bool); 8] = [
498        (false, false, false),
499        (false, false, true),
500        (false, true, false),
501        (false, true, true),
502        (true, false, false),
503        (true, false, true),
504        (true, true, false),
505        (true, true, true),
506    ];
507
508    /// The declared properties of a style vec, in declaration order.
509    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
510        v.as_ref().iter().map(|p| p.property.clone()).collect()
511    }
512
513    /// The *kind* of every declared property, in order (ignores the values).
514    fn property_kinds(
515        v: &CssPropertyWithConditionsVec,
516    ) -> Vec<core::mem::Discriminant<CssProperty>> {
517        v.as_ref().iter().map(|p| core::mem::discriminant(&p.property)).collect()
518    }
519
520    fn declares(v: &CssPropertyWithConditionsVec, pred: impl Fn(&CssProperty) -> bool) -> usize {
521        v.as_ref().iter().filter(|p| pred(&p.property)).count()
522    }
523
524    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length — an
525    /// `em`/`%` slipping into the segment geometry would resolve against the
526    /// parent font/box instead of the intended fixed padding, border or radius.
527    fn px(pv: &PixelValue) -> f32 {
528        assert_eq!(
529            pv.metric,
530            SizeMetric::Px,
531            "segment geometry must be absolute px, got {:?}",
532            pv.metric
533        );
534        pv.number.get()
535    }
536
537    /// The four paddings in `(top, bottom, left, right)` order.
538    fn padding_px(
539        v: &CssPropertyWithConditionsVec,
540    ) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
541        let find =
542            |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
543        (
544            find(&|p| match p {
545                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
546                _ => None,
547            }),
548            find(&|p| match p {
549                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
550                _ => None,
551            }),
552            find(&|p| match p {
553                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
554                _ => None,
555            }),
556            find(&|p| match p {
557                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
558                _ => None,
559            }),
560        )
561    }
562
563    /// The four corner radii as `(top_left, top_right, bottom_left, bottom_right)`.
564    fn radii_px(
565        v: &CssPropertyWithConditionsVec,
566    ) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
567        let find =
568            |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
569        (
570            find(&|p| match p {
571                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
572                _ => None,
573            }),
574            find(&|p| match p {
575                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
576                _ => None,
577            }),
578            find(&|p| match p {
579                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
580                _ => None,
581            }),
582            find(&|p| match p {
583                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
584                _ => None,
585            }),
586        )
587    }
588
589    /// The four border widths as `(top, bottom, left, right)`.
590    fn border_widths_px(
591        v: &CssPropertyWithConditionsVec,
592    ) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
593        let find =
594            |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
595        (
596            find(&|p| match p {
597                CssProperty::BorderTopWidth(x) => x.get_property().map(|x| px(&x.inner)),
598                _ => None,
599            }),
600            find(&|p| match p {
601                CssProperty::BorderBottomWidth(x) => x.get_property().map(|x| px(&x.inner)),
602                _ => None,
603            }),
604            find(&|p| match p {
605                CssProperty::BorderLeftWidth(x) => x.get_property().map(|x| px(&x.inner)),
606                _ => None,
607            }),
608            find(&|p| match p {
609                CssProperty::BorderRightWidth(x) => x.get_property().map(|x| px(&x.inner)),
610                _ => None,
611            }),
612        )
613    }
614
615    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
616        v.as_ref().iter().find_map(|p| match &p.property {
617            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
618            _ => None,
619        })
620    }
621
622    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
623        v.as_ref().iter().find_map(|p| match &p.property {
624            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
625            _ => None,
626        })
627    }
628
629    /// The single background layer of a style vec, asserting there is exactly one
630    /// and that it is a flat colour (a gradient would not be a `Color`).
631    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
632        let bg = v.as_ref().iter().find_map(|p| match &p.property {
633            CssProperty::BackgroundContent(b) => b.get_property(),
634            _ => None,
635        })?;
636        assert_eq!(bg.as_ref().len(), 1, "a segment must declare exactly one background layer");
637        match &bg.as_ref()[0] {
638            StyleBackgroundContent::Color(c) => Some(*c),
639            other => panic!("segment background is not a flat colour: {other:?}"),
640        }
641    }
642
643    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
644    /// plain `+`/`*` (no gamma expansion) so the readability assertions stay exact
645    /// and toolchain-independent.
646    fn luma(c: ColorU) -> f32 {
647        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
648    }
649
650    /// The text of a `NodeType::Text` node (`None` for any other node type).
651    fn text_of(node: &Dom) -> Option<&str> {
652        match node.root.get_node_type() {
653            NodeType::Text(s) => Some(s.as_ref().as_str()),
654            _ => None,
655        }
656    }
657
658    /// The properties of a rendered node's *inline* style, in declaration order.
659    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
660        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
661    }
662
663    /// The true recursive descendant count of a `Dom` — what
664    /// `estimated_total_children` is documented to cache.
665    fn recursive_descendants(node: &Dom) -> usize {
666        node.children.as_ref().iter().map(|c| 1 + recursive_descendants(c)).sum()
667    }
668
669    /// Boundary + "negative" selection indices. `usize` has no negative values, so
670    /// a `-1` handed in through FFI arrives here as `usize::MAX`; both wrapped
671    /// forms are included so the setter is exercised at the two's-complement ends.
672    fn boundary_indices() -> Vec<usize> {
673        vec![
674            0,
675            1,
676            2,
677            usize::MAX / 2,
678            usize::MAX / 2 + 1,
679            usize::MAX - 1,
680            usize::MAX,
681            (-1i64) as usize,
682            i64::MIN as usize,
683            u32::MAX as usize,
684        ]
685    }
686
687    /// Adversarial segment labels: empty, whitespace, combining marks, ZWJ emoji,
688    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
689    /// truncate), control characters, and a string far longer than any plausible
690    /// segment caption.
691    fn adversarial_strings() -> Vec<String> {
692        let mut v: Vec<String> = [
693            "",
694            "Day",
695            " ",
696            "e\u{0301}",                                   // e + combining acute
697            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
698            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
699            "\0",                                          // a single NUL
700            "a\0b",                                        // embedded NUL
701            "\u{FFFD}\u{202E}\u{200B}",                    // replacement, RTL override, ZWSP
702            "line\nbreak\ttab",                            // control characters
703            "-9223372036854775808",                        // i64::MIN as a caption
704        ]
705        .iter()
706        .map(|s| (*s).to_string())
707        .collect();
708        v.push("x".repeat(100_000));
709        v
710    }
711
712    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
713    fn change_cb(f: SegmentedOnChangeCallbackType) -> SegmentedOnChangeCallback {
714        f.into()
715    }
716
717    /// A `RefAny` payload recording every index a user `on_change` sees.
718    struct IndexLog {
719        seen: Vec<usize>,
720    }
721
722    extern "C" fn record_index(mut data: RefAny, _: CallbackInfo, state: SegmentedState) -> Update {
723        if let Some(mut log) = data.downcast_mut::<IndexLog>() {
724            log.seen.push(state.selected_index);
725        }
726        Update::RefreshDom
727    }
728
729    extern "C" fn change_do_nothing(_: RefAny, _: CallbackInfo, _: SegmentedState) -> Update {
730        Update::DoNothing
731    }
732
733    extern "C" fn change_refresh_all(_: RefAny, _: CallbackInfo, state: SegmentedState) -> Update {
734        // `selected_index` is read (and discarded) purely so this body cannot be
735        // identical-code-folded onto another handler; the tests below compare
736        // callback function pointers for equality/inequality.
737        let _ = state.selected_index;
738        Update::RefreshDomAllWindows
739    }
740
741    /// A payload whose callback tries to read the *same* `SegmentedStateWrapper`
742    /// `RefAny` that the handler is currently holding a mutable borrow on.
743    struct ReentrantProbe {
744        /// A clone of the state `RefAny` the handler was invoked with.
745        state: RefAny,
746        /// `Some(index)` if the re-entrant read succeeded, `None` if it was
747        /// refused. Starts as `Some(usize::MAX)` so "never ran" is distinguishable.
748        saw_index: Option<usize>,
749        calls: usize,
750    }
751
752    extern "C" fn probe_state_reentrantly(
753        mut data: RefAny,
754        _: CallbackInfo,
755        _: SegmentedState,
756    ) -> Update {
757        if let Some(mut probe) = data.downcast_mut::<ReentrantProbe>() {
758            probe.calls += 1;
759            let mut state = probe.state.clone();
760            probe.saw_index =
761                state.downcast_ref::<SegmentedStateWrapper>().map(|w| w.inner.selected_index);
762        }
763        Update::DoNothing
764    }
765
766    fn log_indices(data: &mut RefAny) -> Vec<usize> {
767        data.downcast_ref::<IndexLog>().expect("payload must still be an IndexLog").seen.clone()
768    }
769
770    fn selected_index_of(data: &mut RefAny) -> usize {
771        data.downcast_ref::<SegmentedStateWrapper>()
772            .expect("payload must still be a SegmentedStateWrapper")
773            .inner
774            .selected_index
775    }
776
777    /// The `RefAny` carried by segment `i`'s click callback.
778    fn segment_state(dom: &Dom, i: usize) -> RefAny {
779        let cbs = dom.children.as_ref()[i].root.get_callbacks();
780        cbs.as_ref()
781            .first()
782            .expect("every segment must carry the click callback")
783            .refany
784            .clone()
785    }
786
787    /// A `DomLayoutResult` with an *empty* layout tree: `on_segment_click` only
788    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
789    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
790        DomLayoutResult {
791            styled_dom,
792            layout_tree: LayoutTree {
793                nodes: Vec::new(),
794                warm: Vec::new(),
795                cold: Vec::new(),
796                root: 0,
797                dom_to_layout: BTreeMap::new(),
798                children_arena: Vec::new(),
799                children_offsets: Vec::new(),
800                subtree_needs_intrinsic: Vec::new(),
801            },
802            calculated_positions: Vec::new(),
803            viewport: LogicalRect::zero(),
804            display_list: DisplayList::default(),
805            scroll_ids: HashMap::new(),
806            scroll_id_to_node_id: HashMap::new(),
807        }
808    }
809
810    /// Flattens `seg.dom()` and hands back the shared state `RefAny` the segment
811    /// callbacks carry. Requires at least one label.
812    fn flatten(seg: Segmented) -> (StyledDom, RefAny) {
813        let dom = seg.dom();
814        let state = segment_state(&dom, 0);
815        (StyledDom::create_from_dom(dom), state)
816    }
817
818    /// Invokes `on_segment_click` against a `LayoutWindow` holding `styled` (or
819    /// nothing at all, when `styled` is `None`), with node `hit` as the hit node.
820    /// Returns the `Update` plus every recorded `CallbackChange`.
821    fn run_click(
822        styled: Option<StyledDom>,
823        hit: usize,
824        data: RefAny,
825    ) -> (Update, Vec<CallbackChange>) {
826        let mut layout_window =
827            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
828        if let Some(sd) = styled {
829            layout_window.layout_results.insert(DomId::ROOT_ID, layout_result(sd));
830        }
831
832        let renderer_resources = RendererResources::default();
833        let previous_window_state: Option<FullWindowState> = None;
834        let current_window_state = FullWindowState::default();
835        let gl_context = OptionGlContextPtr::None;
836        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
837            BTreeMap::new();
838        let window_handle = RawWindowHandle::Unsupported;
839        let system_callbacks = ExternalSystemCallbacks::rust_internal();
840
841        let ref_data = CallbackInfoRefData {
842            layout_window: &layout_window,
843            renderer_resources: &renderer_resources,
844            previous_window_state: &previous_window_state,
845            current_window_state: &current_window_state,
846            gl_context: &gl_context,
847            current_scroll_manager: &scroll_states,
848            current_window_handle: &window_handle,
849            system_callbacks: &system_callbacks,
850            system_style: Arc::new(SystemStyle::default()),
851            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
852            #[cfg(feature = "icu")]
853            icu_localizer: IcuLocalizerHandle::default(),
854            ctx: OptionRefAny::None,
855        };
856
857        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
858
859        let info = CallbackInfo::new(
860            &ref_data,
861            &changes,
862            DomNodeId {
863                dom: DomId::ROOT_ID,
864                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
865            },
866            OptionLogicalPosition::None,
867            OptionLogicalPosition::None,
868        );
869
870        let update = on_segment_click(data, info);
871        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
872        (update, recorded)
873    }
874
875    /// Every colour the live restyle wrote, as `(node index, "bg" | "text", colour)`
876    /// in emission order. Panics on any property other than the two the handler is
877    /// documented to write.
878    fn restyle_writes(changes: &[CallbackChange]) -> Vec<(usize, &'static str, ColorU)> {
879        let mut out = Vec::new();
880        for change in changes {
881            let CallbackChange::ChangeNodeCssProperties { node_id, properties, .. } = change else {
882                panic!("the restyle must only emit ChangeNodeCssProperties, got {change:?}");
883            };
884            for p in properties.as_ref() {
885                match p {
886                    CssProperty::BackgroundContent(v) => {
887                        let layers =
888                            v.get_property().expect("restyle must write an exact background");
889                        assert_eq!(layers.as_ref().len(), 1, "a segment fill is a single layer");
890                        match &layers.as_ref()[0] {
891                            StyleBackgroundContent::Color(c) => {
892                                out.push((node_id.index(), "bg", *c));
893                            }
894                            other => panic!("segment background is not a flat colour: {other:?}"),
895                        }
896                    }
897                    CssProperty::TextColor(v) => {
898                        let c = v.get_property().expect("restyle must write an exact text colour");
899                        out.push((node_id.index(), "text", c.inner));
900                    }
901                    other => panic!("unexpected restyle property: {other:?}"),
902                }
903            }
904        }
905        out
906    }
907
908    // ------------------------------------------------------------------
909    // build_segment_style
910    // ------------------------------------------------------------------
911
912    #[test]
913    fn build_segment_style_handles_all_eight_flag_combinations() {
914        // 24 shared declarations, +5 for the first segment (left border triple +
915        // the two left radii), +2 for the last (the two right radii).
916        for (selected, first, last) in ALL_FLAGS {
917            let style = build_segment_style(selected, first, last);
918            let expected = 24 + if first { 5 } else { 0 } + if last { 2 } else { 0 };
919            assert_eq!(
920                style.as_ref().len(),
921                expected,
922                "({selected}, {first}, {last}): unexpected declaration count"
923            );
924        }
925    }
926
927    #[test]
928    fn build_segment_style_colours_depend_only_on_selected() {
929        // Position must not leak into the palette: a "first" segment and a
930        // "middle" segment with the same selection must paint identically.
931        for selected in [false, true] {
932            let reference = build_segment_style(selected, false, false);
933            let bg = background_color(&reference).expect("a segment must declare a background");
934            let fg = text_color(&reference).expect("a segment must declare a text colour");
935
936            for (first, last) in [(false, false), (false, true), (true, false), (true, true)] {
937                let style = build_segment_style(selected, first, last);
938                assert_eq!(background_color(&style), Some(bg), "selected={selected}: background moved with position");
939                assert_eq!(text_color(&style), Some(fg), "selected={selected}: text colour moved with position");
940            }
941        }
942
943        assert_eq!(background_color(&build_segment_style(true, false, false)), Some(SEG_SELECTED_BG_COLOR));
944        assert_eq!(text_color(&build_segment_style(true, false, false)), Some(SEG_SELECTED_TEXT));
945        assert_eq!(background_color(&build_segment_style(false, false, false)), Some(SEG_UNSELECTED_BG_COLOR));
946        assert_eq!(text_color(&build_segment_style(false, false, false)), Some(SEG_UNSELECTED_TEXT));
947    }
948
949    #[test]
950    fn build_segment_style_adds_the_left_border_only_to_the_first_segment() {
951        // Every segment paints its own right border, so a non-first segment that
952        // also painted a left one would render a 2px seam between neighbours.
953        for (selected, first, last) in ALL_FLAGS {
954            let style = build_segment_style(selected, first, last);
955            let want = usize::from(first);
956
957            assert_eq!(
958                declares(&style, |p| matches!(p, CssProperty::BorderLeftWidth(_))),
959                want,
960                "({selected}, {first}, {last}): left border width"
961            );
962            assert_eq!(
963                declares(&style, |p| matches!(p, CssProperty::BorderLeftStyle(_))),
964                want,
965                "({selected}, {first}, {last}): left border style"
966            );
967            assert_eq!(
968                declares(&style, |p| matches!(p, CssProperty::BorderLeftColor(_))),
969                want,
970                "({selected}, {first}, {last}): left border colour"
971            );
972        }
973    }
974
975    #[test]
976    fn build_segment_style_rounds_only_the_outer_corners() {
977        for (selected, first, last) in ALL_FLAGS {
978            let style = build_segment_style(selected, first, last);
979            let (tl, tr, bl, br) = radii_px(&style);
980            let r = SEG_RADIUS as f32;
981
982            assert_eq!(tl, first.then_some(r), "({selected}, {first}, {last}): top-left radius");
983            assert_eq!(bl, first.then_some(r), "({selected}, {first}, {last}): bottom-left radius");
984            assert_eq!(tr, last.then_some(r), "({selected}, {first}, {last}): top-right radius");
985            assert_eq!(br, last.then_some(r), "({selected}, {first}, {last}): bottom-right radius");
986        }
987
988        // A lone segment is a fully rounded pill; an interior segment is square.
989        let solo = build_segment_style(true, true, true);
990        let r = SEG_RADIUS as f32;
991        assert_eq!(radii_px(&solo), (Some(r), Some(r), Some(r), Some(r)));
992        let middle = build_segment_style(true, false, false);
993        assert_eq!(radii_px(&middle), (None, None, None, None));
994    }
995
996    #[test]
997    fn build_segment_style_always_paints_the_shared_separator_edges() {
998        // Top/bottom/right must be declared unconditionally — dropping the right
999        // border on the last segment would leave the group open on one side.
1000        for (selected, first, last) in ALL_FLAGS {
1001            let style = build_segment_style(selected, first, last);
1002            let (top, bottom, left, right) = border_widths_px(&style);
1003
1004            assert_eq!(top, Some(1.0), "({selected}, {first}, {last}): top border");
1005            assert_eq!(bottom, Some(1.0), "({selected}, {first}, {last}): bottom border");
1006            assert_eq!(right, Some(1.0), "({selected}, {first}, {last}): right border");
1007            assert_eq!(left, first.then_some(1.0), "({selected}, {first}, {last}): left border");
1008
1009            // A width without a matching style/colour renders as no border at all.
1010            for count in [
1011                declares(&style, |p| matches!(p, CssProperty::BorderTopStyle(_))),
1012                declares(&style, |p| matches!(p, CssProperty::BorderBottomStyle(_))),
1013                declares(&style, |p| matches!(p, CssProperty::BorderRightStyle(_))),
1014                declares(&style, |p| matches!(p, CssProperty::BorderTopColor(_))),
1015                declares(&style, |p| matches!(p, CssProperty::BorderBottomColor(_))),
1016                declares(&style, |p| matches!(p, CssProperty::BorderRightColor(_))),
1017            ] {
1018                assert_eq!(count, 1, "({selected}, {first}, {last}): a shared edge lost its style/colour");
1019            }
1020        }
1021    }
1022
1023    #[test]
1024    fn build_segment_style_border_colours_are_the_single_neutral_grey() {
1025        // A width without a matching colour (or a stray second grey) shows up as
1026        // an inconsistent seam between neighbouring segments.
1027        for (selected, first, last) in ALL_FLAGS {
1028            let style = build_segment_style(selected, first, last);
1029            for p in style.as_ref() {
1030                let found = match &p.property {
1031                    CssProperty::BorderTopColor(c) => c.get_property().map(|c| c.inner),
1032                    CssProperty::BorderBottomColor(c) => c.get_property().map(|c| c.inner),
1033                    CssProperty::BorderLeftColor(c) => c.get_property().map(|c| c.inner),
1034                    CssProperty::BorderRightColor(c) => c.get_property().map(|c| c.inner),
1035                    _ => None,
1036                };
1037                if let Some(c) = found {
1038                    assert_eq!(
1039                        c, SEG_BORDER_COLOR,
1040                        "({selected}, {first}, {last}): border colour {c:?} is not the shared grey"
1041                    );
1042                }
1043            }
1044        }
1045    }
1046
1047    #[test]
1048    fn build_segment_style_geometry_is_absolute_and_symmetric() {
1049        for (selected, first, last) in ALL_FLAGS {
1050            let style = build_segment_style(selected, first, last);
1051            // padding: 6px 12px — `px()` asserts the metric on every value it reads.
1052            assert_eq!(
1053                padding_px(&style),
1054                (Some(6.0), Some(6.0), Some(12.0), Some(12.0)),
1055                "({selected}, {first}, {last}): padding is not 6px 12px"
1056            );
1057            assert_eq!(font_size_px(&style), Some(13.0), "({selected}, {first}, {last}): font size");
1058        }
1059    }
1060
1061    #[test]
1062    fn build_segment_style_declares_every_property_unconditionally() {
1063        // `simple()` means an empty `apply_if`. A stray condition here would make
1064        // the segment silently unstyled until some selector state happened to match.
1065        for (selected, first, last) in ALL_FLAGS {
1066            let style = build_segment_style(selected, first, last);
1067            for p in style.as_ref() {
1068                assert!(
1069                    p.apply_if.as_ref().is_empty(),
1070                    "({selected}, {first}, {last}): {:?} is conditional",
1071                    p.property
1072                );
1073            }
1074        }
1075    }
1076
1077    #[test]
1078    fn build_segment_style_never_declares_the_same_property_twice() {
1079        // Duplicates are silently last-wins, so a doubled declaration hides a
1080        // genuine value conflict instead of failing loudly.
1081        for (selected, first, last) in ALL_FLAGS {
1082            let style = build_segment_style(selected, first, last);
1083            let mut seen = HashSet::new();
1084            for kind in property_kinds(&style) {
1085                assert!(
1086                    seen.insert(kind),
1087                    "({selected}, {first}, {last}): duplicate property kind in the style vec"
1088                );
1089            }
1090            assert_eq!(seen.len(), style.as_ref().len());
1091        }
1092    }
1093
1094    #[test]
1095    fn build_segment_style_is_pure() {
1096        // Called once per segment on every `dom()`; a hidden `static mut` cache or
1097        // an accumulating vec would show up as drift between two identical calls.
1098        for (selected, first, last) in ALL_FLAGS {
1099            let a = build_segment_style(selected, first, last);
1100            let b = build_segment_style(selected, first, last);
1101            assert_eq!(properties(&a), properties(&b), "({selected}, {first}, {last}): not pure");
1102        }
1103    }
1104
1105    #[test]
1106    fn build_segment_style_keeps_the_label_readable_and_opaque() {
1107        for selected in [false, true] {
1108            let style = build_segment_style(selected, false, false);
1109            let bg = background_color(&style).expect("background");
1110            let fg = text_color(&style).expect("text colour");
1111
1112            assert_eq!(bg.a, 255, "selected={selected}: a translucent fill lets the page bleed through");
1113            assert_eq!(fg.a, 255, "selected={selected}: translucent label text");
1114            assert_ne!(bg, fg, "selected={selected}: an invisible label is not a segment");
1115            assert!(
1116                (luma(bg) - luma(fg)).abs() >= 60.0,
1117                "selected={selected}: brightness gap {:.1} is too low to read",
1118                (luma(bg) - luma(fg)).abs()
1119            );
1120        }
1121
1122        // The two states must be visually distinguishable — that is the entire
1123        // point of a segmented control.
1124        let sel = build_segment_style(true, false, false);
1125        let unsel = build_segment_style(false, false, false);
1126        assert_ne!(background_color(&sel), background_color(&unsel));
1127        assert_ne!(text_color(&sel), text_color(&unsel));
1128    }
1129
1130    #[test]
1131    fn build_segment_style_declares_the_interaction_affordances() {
1132        for (selected, first, last) in ALL_FLAGS {
1133            let style = build_segment_style(selected, first, last);
1134            let ctx = format!("({selected}, {first}, {last})");
1135
1136            assert!(
1137                style.as_ref().iter().any(|p| matches!(
1138                    &p.property,
1139                    CssProperty::Cursor(c) if c.get_property() == Some(&StyleCursor::Pointer)
1140                )),
1141                "{ctx}: a clickable segment must show the pointer cursor"
1142            );
1143            assert!(
1144                style.as_ref().iter().any(|p| matches!(
1145                    &p.property,
1146                    CssProperty::UserSelect(u) if u.get_property() == Some(&StyleUserSelect::None)
1147                )),
1148                "{ctx}: click-dragging a segment must not select its caption"
1149            );
1150            assert!(
1151                style.as_ref().iter().any(|p| matches!(
1152                    &p.property,
1153                    CssProperty::TextAlign(t) if t.get_property() == Some(&StyleTextAlign::Center)
1154                )),
1155                "{ctx}: captions are centred"
1156            );
1157            assert!(
1158                style.as_ref().iter().any(|p| matches!(
1159                    &p.property,
1160                    CssProperty::FlexGrow(f) if f.get_property().map(|f| f.inner.get()) == Some(0.0)
1161                )),
1162                "{ctx}: segments hug their caption, they do not stretch"
1163            );
1164        }
1165    }
1166
1167    // ------------------------------------------------------------------
1168    // Segmented::create
1169    // ------------------------------------------------------------------
1170
1171    #[test]
1172    fn create_preserves_labels_verbatim() {
1173        for case in [
1174            vec![],
1175            vec!["only"],
1176            vec!["Day", "Week"],
1177            vec!["Day", "Week", "Month", "Year"],
1178            vec!["dup", "dup", "dup"],
1179        ] {
1180            let seg = Segmented::create(labels(&case));
1181            let got: Vec<&str> = seg.labels.as_ref().iter().map(AzString::as_str).collect();
1182            assert_eq!(got, case, "create must not reorder/drop/dedupe/rewrite labels");
1183        }
1184    }
1185
1186    #[test]
1187    fn create_preserves_adversarial_labels_byte_for_byte() {
1188        for s in adversarial_strings() {
1189            let seg = Segmented::create(labels(&[s.as_str()]));
1190            let stored = seg.labels.as_ref()[0].as_str();
1191            assert_eq!(stored, s.as_str(), "the caption changed on its way into the widget");
1192            assert_eq!(
1193                seg.labels.as_ref()[0].as_ref().len(),
1194                s.len(),
1195                "byte length changed (NUL truncation?)"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn create_selects_the_first_segment_and_installs_no_callback() {
1202        for n in [0usize, 1, 2, 7] {
1203            let seg = Segmented::create(n_labels(n));
1204            assert_eq!(
1205                seg.segmented_state.inner.selected_index, 0,
1206                "n={n}: a fresh control starts on segment 0"
1207            );
1208            assert!(
1209                seg.segmented_state.on_change.as_ref().is_none(),
1210                "n={n}: create must not wire a callback"
1211            );
1212        }
1213    }
1214
1215    #[test]
1216    fn create_installs_the_shared_container_style() {
1217        let seg = Segmented::create(labels(&["a", "b"]));
1218        assert_eq!(
1219            seg.container_style.as_ref(),
1220            SEGMENTED_CONTAINER_STYLE,
1221            "create must install the shared container style"
1222        );
1223
1224        // Decode the semantics too, so a silent edit of the const is caught here
1225        // rather than only in a screenshot: a horizontal, content-hugging row.
1226        let style = &seg.container_style;
1227        assert_eq!(
1228            declares(style, |p| matches!(
1229                p, CssProperty::Display(d) if d.get_property() == Some(&LayoutDisplay::Flex))),
1230            1,
1231            "the row container must be a flex box"
1232        );
1233        assert_eq!(
1234            declares(style, |p| matches!(
1235                p, CssProperty::FlexDirection(d) if d.get_property() == Some(&LayoutFlexDirection::Row))),
1236            1,
1237            "segments are joined horizontally"
1238        );
1239        assert_eq!(
1240            declares(style, |p| matches!(
1241                p, CssProperty::AlignItems(a) if a.get_property() == Some(&LayoutAlignItems::Center))),
1242            1
1243        );
1244        assert_eq!(
1245            declares(style, |p| matches!(
1246                p, CssProperty::AlignSelf(a) if a.get_property() == Some(&LayoutAlignSelf::Start))),
1247            1
1248        );
1249        assert_eq!(
1250            declares(style, |p| matches!(
1251                p, CssProperty::FlexGrow(f) if f.get_property().map(|f| f.inner.get()) == Some(0.0))),
1252            1,
1253            "the group hugs its segments instead of filling the parent"
1254        );
1255
1256        for p in seg.container_style.as_ref() {
1257            assert!(p.apply_if.as_ref().is_empty(), "{:?} is conditional", p.property);
1258        }
1259    }
1260
1261    #[test]
1262    fn create_with_no_labels_equals_default() {
1263        let empty = Segmented::create(StringVec::from_const_slice(&[]));
1264        assert_eq!(empty, Segmented::default(), "Default must be the empty control");
1265        assert_eq!(empty.labels.as_ref().len(), 0);
1266        assert!(Segmented::default().segmented_state.on_change.as_ref().is_none());
1267    }
1268
1269    #[test]
1270    fn create_scales_to_a_very_long_label_list() {
1271        let n = 4096;
1272        let seg = Segmented::create(n_labels(n));
1273        assert_eq!(seg.labels.as_ref().len(), n);
1274        assert_eq!(seg.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
1275        assert_eq!(seg.segmented_state.inner.selected_index, 0);
1276    }
1277
1278    // ------------------------------------------------------------------
1279    // Segmented::set_selected_index  /  with_selected_index
1280    // ------------------------------------------------------------------
1281
1282    #[test]
1283    fn set_selected_index_stores_every_boundary_value_verbatim() {
1284        // The setter is a plain field write: no clamping, no wrapping, no panic —
1285        // not even at `usize::MAX` or at a `-1` that arrived through FFI.
1286        for i in boundary_indices() {
1287            let mut seg = Segmented::create(labels(&["a", "b", "c"]));
1288            seg.set_selected_index(i);
1289            assert_eq!(seg.segmented_state.inner.selected_index, i, "index {i} was not stored as-is");
1290        }
1291    }
1292
1293    #[test]
1294    fn set_selected_index_does_not_clamp_to_the_label_count() {
1295        // Documenting the actual contract: an out-of-range index is *accepted*
1296        // and simply selects nothing when rendered (see the `dom_` tests below).
1297        let mut seg = Segmented::create(labels(&["a", "b"]));
1298        for i in [2usize, 3, 1_000, usize::MAX] {
1299            seg.set_selected_index(i);
1300            assert_eq!(seg.segmented_state.inner.selected_index, i);
1301            assert_eq!(seg.labels.as_ref().len(), 2, "the setter must not touch the labels");
1302        }
1303    }
1304
1305    #[test]
1306    fn set_selected_index_is_idempotent_and_last_write_wins() {
1307        let mut seg = Segmented::create(labels(&["a", "b", "c"]));
1308        for i in [1usize, 1, 1] {
1309            seg.set_selected_index(i);
1310        }
1311        assert_eq!(seg.segmented_state.inner.selected_index, 1);
1312
1313        for i in [0usize, usize::MAX, 2, 0] {
1314            seg.set_selected_index(i);
1315        }
1316        assert_eq!(seg.segmented_state.inner.selected_index, 0, "the last write must win");
1317    }
1318
1319    #[test]
1320    fn set_selected_index_leaves_every_other_field_alone() {
1321        let mut seg = Segmented::create(labels(&["a", "b"]))
1322            .with_on_change(RefAny::new(7u8), change_cb(change_do_nothing));
1323        let before = seg.clone();
1324
1325        seg.set_selected_index(usize::MAX);
1326
1327        assert_eq!(seg.labels, before.labels, "labels changed");
1328        assert_eq!(seg.container_style, before.container_style, "container style changed");
1329        assert_eq!(
1330            seg.segmented_state.on_change, before.segmented_state.on_change,
1331            "the callback was disturbed"
1332        );
1333    }
1334
1335    #[test]
1336    fn with_selected_index_round_trips_through_the_setter() {
1337        for i in boundary_indices() {
1338            let via_builder = Segmented::create(labels(&["a", "b"])).with_selected_index(i);
1339            let mut via_setter = Segmented::create(labels(&["a", "b"]));
1340            via_setter.set_selected_index(i);
1341
1342            assert_eq!(via_builder, via_setter, "index {i}: builder and setter diverge");
1343            assert_eq!(via_builder.segmented_state.inner.selected_index, i);
1344        }
1345    }
1346
1347    #[test]
1348    fn with_selected_index_preserves_the_rest_of_the_widget() {
1349        let base = Segmented::create(labels(&["a", "b", "c"]));
1350        let built = base.clone().with_selected_index(2);
1351
1352        assert_eq!(built.labels, base.labels);
1353        assert_eq!(built.container_style, base.container_style);
1354        assert_eq!(built.labels.as_ref().len(), 3, "len/contents must stay consistent");
1355        assert!(built.segmented_state.on_change.as_ref().is_none());
1356    }
1357
1358    #[test]
1359    fn with_selected_index_chains_with_last_wins() {
1360        let seg = Segmented::create(labels(&["a", "b", "c"]))
1361            .with_selected_index(usize::MAX)
1362            .with_selected_index(0)
1363            .with_selected_index(2);
1364        assert_eq!(seg.segmented_state.inner.selected_index, 2);
1365    }
1366
1367    // ------------------------------------------------------------------
1368    // Segmented::swap_with_default
1369    // ------------------------------------------------------------------
1370
1371    #[test]
1372    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
1373        let mut seg = Segmented::create(labels(&["Day", "Week", "Month"])).with_selected_index(2);
1374        let expected = seg.clone();
1375
1376        let taken = seg.swap_with_default();
1377
1378        assert_eq!(taken, expected, "the caller must get the original widget back");
1379        assert_eq!(seg, Segmented::default(), "a default must be left in its place");
1380        assert_eq!(seg.labels.as_ref().len(), 0);
1381        assert_eq!(seg.segmented_state.inner.selected_index, 0);
1382    }
1383
1384    #[test]
1385    fn swap_with_default_moves_the_callback_out_with_the_widget() {
1386        let mut seg = Segmented::create(labels(&["a", "b"]))
1387            .with_on_change(RefAny::new(1u8), change_cb(record_index));
1388
1389        let taken = seg.swap_with_default();
1390
1391        assert!(
1392            taken.segmented_state.on_change.as_ref().is_some(),
1393            "the callback must travel with the taken widget"
1394        );
1395        assert!(
1396            seg.segmented_state.on_change.as_ref().is_none(),
1397            "the leftover default must not keep a handle on the callback"
1398        );
1399    }
1400
1401    #[test]
1402    fn swap_with_default_on_a_default_is_a_no_op() {
1403        let mut seg = Segmented::default();
1404        let taken = seg.swap_with_default();
1405        assert_eq!(taken, Segmented::default());
1406        assert_eq!(seg, Segmented::default());
1407    }
1408
1409    #[test]
1410    fn swap_with_default_twice_yields_a_default_the_second_time() {
1411        let mut seg = Segmented::create(labels(&["a", "b"])).with_selected_index(1);
1412        let first = seg.swap_with_default();
1413        let second = seg.swap_with_default();
1414
1415        assert_eq!(first.labels.as_ref().len(), 2);
1416        assert_eq!(first.segmented_state.inner.selected_index, 1);
1417        assert_eq!(second, Segmented::default(), "the second take is the default we left behind");
1418        assert_eq!(seg, Segmented::default());
1419    }
1420
1421    #[test]
1422    fn swap_with_default_does_not_truncate_a_large_label_list() {
1423        let n = 1024;
1424        let mut seg = Segmented::create(n_labels(n)).with_selected_index(n - 1);
1425        let taken = seg.swap_with_default();
1426
1427        assert_eq!(taken.labels.as_ref().len(), n);
1428        assert_eq!(taken.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
1429        assert_eq!(taken.segmented_state.inner.selected_index, n - 1);
1430    }
1431
1432    // ------------------------------------------------------------------
1433    // Segmented::set_on_change  /  with_on_change
1434    // ------------------------------------------------------------------
1435
1436    #[test]
1437    fn set_on_change_installs_the_callback_and_its_payload() {
1438        let mut seg = Segmented::create(labels(&["a", "b"]));
1439        let mut payload = RefAny::new(IndexLog { seen: Vec::new() });
1440        seg.set_on_change(payload.clone(), change_cb(record_index));
1441
1442        let installed =
1443            seg.segmented_state.on_change.as_ref().expect("set_on_change must install a callback");
1444        assert_eq!(installed.callback.cb as usize, record_index as usize, "wrong function installed");
1445        assert!(
1446            matches!(installed.callback.ctx, OptionRefAny::None),
1447            "a native Rust callback carries no FFI context"
1448        );
1449
1450        // The stored `RefAny` must be a *share* of the caller's, not a copy:
1451        // writing through the widget's handle must be visible to the caller.
1452        let mut stored = installed.refany.clone();
1453        {
1454            let mut log = stored.downcast_mut::<IndexLog>().expect("payload type must survive");
1455            log.seen.push(42);
1456        }
1457        assert_eq!(log_indices(&mut payload), vec![42], "the payload was copied, not shared");
1458    }
1459
1460    #[test]
1461    fn set_on_change_overwrites_a_previously_installed_callback() {
1462        let mut seg = Segmented::create(labels(&["a", "b"]));
1463        seg.set_on_change(RefAny::new(1u8), change_cb(record_index));
1464        seg.set_on_change(RefAny::new(2u8), change_cb(change_refresh_all));
1465
1466        let installed = seg.segmented_state.on_change.as_ref().expect("callback");
1467        assert_eq!(installed.callback.cb as usize, change_refresh_all as usize, "the last setter must win");
1468        assert_ne!(installed.callback.cb as usize, record_index as usize);
1469    }
1470
1471    #[test]
1472    fn set_on_change_does_not_disturb_labels_or_selection() {
1473        let mut seg = Segmented::create(labels(&["a", "b", "c"])).with_selected_index(2);
1474        seg.set_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
1475
1476        assert_eq!(seg.labels.as_ref().len(), 3);
1477        assert_eq!(seg.segmented_state.inner.selected_index, 2, "installing a callback moved the selection");
1478    }
1479
1480    #[test]
1481    fn with_on_change_matches_the_setter_exactly() {
1482        let payload = RefAny::new(9u8);
1483        let via_builder = Segmented::create(labels(&["a", "b"]))
1484            .with_on_change(payload.clone(), change_cb(change_do_nothing));
1485        let mut via_setter = Segmented::create(labels(&["a", "b"]));
1486        via_setter.set_on_change(payload, change_cb(change_do_nothing));
1487
1488        assert_eq!(via_builder, via_setter, "builder and setter must produce the same widget");
1489    }
1490
1491    #[test]
1492    fn with_on_change_holds_its_invariants_after_construction() {
1493        let seg = Segmented::create(n_labels(5))
1494            .with_selected_index(3)
1495            .with_on_change(RefAny::new(0u8), change_cb(change_refresh_all));
1496
1497        assert_eq!(seg.labels.as_ref().len(), 5, "label count must survive the builder chain");
1498        assert_eq!(seg.segmented_state.inner.selected_index, 3, "the selection must survive");
1499        assert_eq!(
1500            seg.container_style.as_ref(),
1501            SEGMENTED_CONTAINER_STYLE,
1502            "the container style must survive"
1503        );
1504        let installed = seg.segmented_state.on_change.as_ref().expect("callback");
1505        assert_eq!(installed.callback.cb as usize, change_refresh_all as usize);
1506    }
1507
1508    #[test]
1509    fn with_on_change_chains_with_last_wins() {
1510        let seg = Segmented::create(labels(&["a"]))
1511            .with_on_change(RefAny::new(0u8), change_cb(record_index))
1512            .with_on_change(RefAny::new(0u8), change_cb(change_do_nothing));
1513        let installed = seg.segmented_state.on_change.as_ref().expect("callback");
1514        assert_eq!(installed.callback.cb as usize, change_do_nothing as usize);
1515    }
1516
1517    // ------------------------------------------------------------------
1518    // Segmented::dom
1519    // ------------------------------------------------------------------
1520
1521    #[test]
1522    fn dom_emits_one_text_child_per_label_in_order() {
1523        let case = ["Day", "Week", "Month", "Year"];
1524        let dom = Segmented::create(labels(&case)).dom();
1525
1526        assert!(matches!(dom.root.get_node_type(), NodeType::Div), "the group is a div");
1527        assert!(dom.root.has_class("__azul-native-segmented"));
1528        assert!(dom.root.get_callbacks().as_ref().is_empty(), "the container itself is not clickable");
1529
1530        let children = dom.children.as_ref();
1531        assert_eq!(children.len(), case.len());
1532        for (i, child) in children.iter().enumerate() {
1533            assert_eq!(text_of(child), Some(case[i]), "segment {i} shows the wrong caption");
1534            assert!(child.root.has_class("__azul-native-segmented-item"), "segment {i} lost its class");
1535        }
1536    }
1537
1538    #[test]
1539    fn dom_of_an_empty_control_is_a_childless_container() {
1540        // `count == 0` must not underflow `i + 1 == count` or emit a stray child.
1541        let dom = Segmented::create(StringVec::from_const_slice(&[])).dom();
1542        assert_eq!(dom.children.as_ref().len(), 0);
1543        assert_eq!(dom.estimated_total_children, 0);
1544        assert!(dom.root.has_class("__azul-native-segmented"));
1545
1546        let styled = StyledDom::create_from_dom(dom);
1547        assert_eq!(styled.node_hierarchy.as_ref().len(), 1, "just the container");
1548    }
1549
1550    #[test]
1551    fn dom_styles_each_segment_by_its_position_and_selection() {
1552        for n in [1usize, 2, 3, 5] {
1553            for selected in 0..n {
1554                let dom = Segmented::create(n_labels(n)).with_selected_index(selected).dom();
1555                let children = dom.children.as_ref();
1556                assert_eq!(children.len(), n);
1557
1558                for (i, child) in children.iter().enumerate() {
1559                    let expected =
1560                        properties(&build_segment_style(i == selected, i == 0, i + 1 == n));
1561                    assert_eq!(
1562                        inline_properties(child),
1563                        expected,
1564                        "n={n} selected={selected}: segment {i} carries the wrong style"
1565                    );
1566                }
1567            }
1568        }
1569    }
1570
1571    #[test]
1572    fn dom_marks_exactly_one_segment_as_selected() {
1573        for n in [1usize, 2, 4] {
1574            for selected in 0..n {
1575                let dom = Segmented::create(n_labels(n)).with_selected_index(selected).dom();
1576                let marked: Vec<usize> = dom
1577                    .children
1578                    .as_ref()
1579                    .iter()
1580                    .enumerate()
1581                    .filter(|(_, c)| {
1582                        inline_properties(c).iter().any(|p| matches!(
1583                            p, CssProperty::TextColor(t)
1584                                if t.get_property().map(|t| t.inner) == Some(SEG_SELECTED_TEXT)))
1585                    })
1586                    .map(|(i, _)| i)
1587                    .collect();
1588                assert_eq!(marked, vec![selected], "n={n}: mutual exclusivity broken");
1589            }
1590        }
1591    }
1592
1593    #[test]
1594    fn dom_with_an_out_of_range_selection_marks_nothing_and_does_not_panic() {
1595        // `set_selected_index` accepts any `usize`; rendering must degrade to
1596        // "nothing selected" rather than panicking or wrapping onto a real segment.
1597        let n = 3;
1598        for selected in [n, n + 1, 1_000, usize::MAX, usize::MAX - 1] {
1599            let dom = Segmented::create(n_labels(n)).with_selected_index(selected).dom();
1600            assert_eq!(dom.children.as_ref().len(), n, "selected={selected}: child count changed");
1601
1602            for (i, child) in dom.children.as_ref().iter().enumerate() {
1603                let expected = properties(&build_segment_style(false, i == 0, i + 1 == n));
1604                assert_eq!(
1605                    inline_properties(child),
1606                    expected,
1607                    "selected={selected}: segment {i} must render unselected"
1608                );
1609            }
1610        }
1611    }
1612
1613    #[test]
1614    fn dom_rounds_only_the_two_outer_segments() {
1615        let n = 4;
1616        let dom = Segmented::create(n_labels(n)).dom();
1617        let r = SEG_RADIUS as f32;
1618
1619        let radii_of = |child: &Dom| -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
1620            let props = inline_properties(child);
1621            let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| props.iter().find_map(f);
1622            (
1623                find(&|p| match p {
1624                    CssProperty::BorderTopLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
1625                    _ => None,
1626                }),
1627                find(&|p| match p {
1628                    CssProperty::BorderTopRightRadius(x) => x.get_property().map(|x| px(&x.inner)),
1629                    _ => None,
1630                }),
1631                find(&|p| match p {
1632                    CssProperty::BorderBottomLeftRadius(x) => x.get_property().map(|x| px(&x.inner)),
1633                    _ => None,
1634                }),
1635                find(&|p| match p {
1636                    CssProperty::BorderBottomRightRadius(x) => {
1637                        x.get_property().map(|x| px(&x.inner))
1638                    }
1639                    _ => None,
1640                }),
1641            )
1642        };
1643
1644        let children = dom.children.as_ref();
1645        assert_eq!(radii_of(&children[0]), (Some(r), None, Some(r), None), "first: left corners only");
1646        assert_eq!(radii_of(&children[1]), (None, None, None, None), "interior segments are square");
1647        assert_eq!(radii_of(&children[2]), (None, None, None, None), "interior segments are square");
1648        assert_eq!(
1649            radii_of(&children[3]),
1650            (None, Some(r), None, Some(r)),
1651            "last: right corners only"
1652        );
1653    }
1654
1655    #[test]
1656    fn dom_of_a_single_segment_is_rounded_on_both_ends() {
1657        let dom = Segmented::create(labels(&["only"])).dom();
1658        let children = dom.children.as_ref();
1659        assert_eq!(children.len(), 1);
1660
1661        let expected = properties(&build_segment_style(true, true, true));
1662        assert_eq!(
1663            inline_properties(&children[0]),
1664            expected,
1665            "a lone segment is simultaneously first and last"
1666        );
1667    }
1668
1669    #[test]
1670    fn dom_makes_every_segment_clickable_and_keyboard_reachable() {
1671        let n = 3;
1672        let dom = Segmented::create(n_labels(n)).dom();
1673        for (i, child) in dom.children.as_ref().iter().enumerate() {
1674            let cbs = child.root.get_callbacks();
1675            assert_eq!(cbs.as_ref().len(), 1, "segment {i}: exactly one handler");
1676            assert_eq!(cbs.as_ref()[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
1677            assert_eq!(cbs.as_ref()[0].callback.cb, on_segment_click as usize);
1678            assert!(matches!(cbs.as_ref()[0].callback.ctx, OptionRefAny::None));
1679            assert_eq!(
1680                child.root.get_tab_index(),
1681                Some(TabIndex::Auto),
1682                "segment {i} must be tab-reachable"
1683            );
1684        }
1685    }
1686
1687    #[test]
1688    fn dom_shares_one_state_refany_across_every_segment() {
1689        // The handler resolves the clicked index from the DOM, so all segments
1690        // *must* observe the same state — a per-segment copy would let two
1691        // segments believe they are both selected.
1692        let dom = Segmented::create(n_labels(4)).dom();
1693
1694        let mut first = segment_state(&dom, 0);
1695        {
1696            let mut w = first
1697                .downcast_mut::<SegmentedStateWrapper>()
1698                .expect("segment state must be a SegmentedStateWrapper");
1699            w.inner.selected_index = 3;
1700        }
1701
1702        for i in 1..4 {
1703            let mut other = segment_state(&dom, i);
1704            assert_eq!(
1705                selected_index_of(&mut other),
1706                3,
1707                "segment {i} does not share segment 0's state"
1708            );
1709        }
1710    }
1711
1712    #[test]
1713    fn dom_carries_the_installed_callback_into_the_shared_state() {
1714        let dom = Segmented::create(labels(&["a", "b"]))
1715            .with_on_change(RefAny::new(0u8), change_cb(change_refresh_all))
1716            .dom();
1717        let mut state = segment_state(&dom, 0);
1718        let wrapper =
1719            state.downcast_ref::<SegmentedStateWrapper>().expect("SegmentedStateWrapper");
1720        let installed = wrapper.on_change.as_ref().expect("the user callback must reach the DOM");
1721        assert_eq!(installed.callback.cb as usize, change_refresh_all as usize);
1722    }
1723
1724    #[test]
1725    fn dom_preserves_adversarial_labels_verbatim() {
1726        for s in adversarial_strings() {
1727            let dom = Segmented::create(labels(&[s.as_str(), "other"])).dom();
1728            let children = dom.children.as_ref();
1729            assert_eq!(children.len(), 2);
1730            match children[0].root.get_node_type() {
1731                NodeType::Text(t) => {
1732                    assert_eq!(t.as_ref().as_str(), s.as_str(), "the caption changed inside dom()");
1733                    assert_eq!(t.as_ref().len(), s.len(), "byte length changed (NUL truncation?)");
1734                }
1735                other => panic!("expected a text node, got {other:?}"),
1736            }
1737        }
1738    }
1739
1740    #[test]
1741    fn dom_keeps_estimated_total_children_in_sync() {
1742        // `estimated_total_children` is a cached count; if it under-counts,
1743        // `convert_dom_into_compact_dom` under-allocates and panics.
1744        for n in [0usize, 1, 2, 3, 5, 64, 257] {
1745            let dom = Segmented::create(n_labels(n)).dom();
1746            assert_eq!(dom.children.as_ref().len(), n, "child count for n={n}");
1747            assert_eq!(
1748                dom.estimated_total_children,
1749                recursive_descendants(&dom),
1750                "cached descendant count desynced for n={n}"
1751            );
1752            assert_eq!(dom.estimated_total_children, n, "for n={n}");
1753        }
1754    }
1755
1756    #[test]
1757    fn dom_of_many_segments_flattens_without_panicking() {
1758        let n = 512;
1759        let styled = StyledDom::create_from_dom(Segmented::create(n_labels(n)).dom());
1760        assert_eq!(styled.node_hierarchy.as_ref().len(), n + 1, "root + n segments");
1761    }
1762
1763    #[test]
1764    fn dom_via_from_matches_dom_exactly() {
1765        let build = || Segmented::create(n_labels(3)).with_selected_index(1);
1766        let via_into: Dom = build().into();
1767        let via_dom = build().dom();
1768
1769        assert_eq!(via_into.children.as_ref().len(), via_dom.children.as_ref().len());
1770        assert_eq!(
1771            via_into.estimated_total_children,
1772            via_dom.estimated_total_children
1773        );
1774        for i in 0..via_dom.children.as_ref().len() {
1775            assert_eq!(
1776                inline_properties(&via_into.children.as_ref()[i]),
1777                inline_properties(&via_dom.children.as_ref()[i]),
1778                "`From` diverges from `dom()` at segment {i}"
1779            );
1780            assert_eq!(
1781                text_of(&via_into.children.as_ref()[i]),
1782                text_of(&via_dom.children.as_ref()[i])
1783            );
1784        }
1785    }
1786
1787    #[test]
1788    fn dom_with_duplicate_labels_still_produces_distinct_positional_segments() {
1789        // Selection is positional, not by caption: three identical captions must
1790        // still give exactly one selected segment, at the requested position.
1791        let dom = Segmented::create(labels(&["same", "same", "same"])).with_selected_index(1).dom();
1792        let children = dom.children.as_ref();
1793        for (i, child) in children.iter().enumerate() {
1794            assert_eq!(text_of(child), Some("same"));
1795            let expected = properties(&build_segment_style(i == 1, i == 0, i == 2));
1796            assert_eq!(inline_properties(child), expected, "segment {i}");
1797        }
1798    }
1799
1800    // ------------------------------------------------------------------
1801    // on_segment_click
1802    // ------------------------------------------------------------------
1803
1804    #[test]
1805    fn click_selects_the_segment_at_the_clicked_position() {
1806        let n = 4;
1807        let (styled, state) = flatten(Segmented::create(n_labels(n)));
1808        assert_eq!(styled.node_hierarchy.as_ref().len(), n + 1, "fixture: root + n segments");
1809
1810        for i in 0..n {
1811            let mut state = state.clone();
1812            let (update, changes) = run_click(Some(styled.clone()), i + 1, state.clone());
1813
1814            assert_eq!(
1815                update,
1816                Update::DoNothing,
1817                "with no on_change installed the handler reports nothing to redraw"
1818            );
1819            assert_eq!(selected_index_of(&mut state), i, "node {} must select segment {i}", i + 1);
1820            assert_eq!(restyle_writes(&changes).len(), 2 * n, "every segment must be restyled");
1821        }
1822    }
1823
1824    #[test]
1825    fn click_restyle_agrees_with_a_freshly_built_style() {
1826        // The live restyle and a full rebuild must not drift apart, or a click
1827        // followed by a `RefreshDom` would visibly change the widget twice.
1828        let n = 4;
1829        let (styled, state) = flatten(Segmented::create(n_labels(n)));
1830
1831        for clicked in 0..n {
1832            let (_, changes) = run_click(Some(styled.clone()), clicked + 1, state.clone());
1833            let writes = restyle_writes(&changes);
1834            assert_eq!(writes.len(), 2 * n);
1835
1836            for i in 0..n {
1837                let fresh = build_segment_style(i == clicked, i == 0, i + 1 == n);
1838                assert_eq!(
1839                    writes[2 * i],
1840                    (i + 1, "bg", background_color(&fresh).expect("background")),
1841                    "clicked={clicked}: segment {i} background"
1842                );
1843                assert_eq!(
1844                    writes[2 * i + 1],
1845                    (i + 1, "text", text_color(&fresh).expect("text colour")),
1846                    "clicked={clicked}: segment {i} text colour"
1847                );
1848            }
1849        }
1850    }
1851
1852    #[test]
1853    fn click_invokes_the_user_callback_with_the_updated_state() {
1854        let mut log = RefAny::new(IndexLog { seen: Vec::new() });
1855        let seg = Segmented::create(n_labels(4))
1856            .with_on_change(log.clone(), change_cb(record_index));
1857        let (styled, state) = flatten(seg);
1858
1859        let (update, changes) = run_click(Some(styled.clone()), 3, state.clone());
1860        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
1861        assert_eq!(log_indices(&mut log), vec![2], "the callback sees the *new* index");
1862        assert_eq!(restyle_writes(&changes).len(), 8, "the restyle must still run");
1863
1864        // A second click updates the shared state again — the index is not sticky.
1865        let (_, _) = run_click(Some(styled), 1, state.clone());
1866        assert_eq!(log_indices(&mut log), vec![2, 0]);
1867
1868        let mut state = state;
1869        assert_eq!(selected_index_of(&mut state), 0, "the state holds the *last* clicked index");
1870    }
1871
1872    #[test]
1873    fn click_propagates_every_update_variant_unchanged() {
1874        for (cb, expected) in [
1875            (change_cb(change_do_nothing), Update::DoNothing),
1876            (change_cb(change_refresh_all), Update::RefreshDomAllWindows),
1877        ] {
1878            let seg =
1879                Segmented::create(labels(&["a", "b"])).with_on_change(RefAny::new(0u8), cb);
1880            let (styled, state) = flatten(seg);
1881            let (update, changes) = run_click(Some(styled), 2, state);
1882            assert_eq!(update, expected);
1883            assert_eq!(
1884                restyle_writes(&changes).len(),
1885                4,
1886                "the restyle runs regardless of what the user returns"
1887            );
1888        }
1889    }
1890
1891    #[test]
1892    fn click_restyles_even_without_a_user_callback() {
1893        let (styled, state) = flatten(Segmented::create(labels(&["a", "b"])));
1894        let (update, changes) = run_click(Some(styled), 1, state);
1895
1896        assert_eq!(update, Update::DoNothing);
1897        assert_eq!(
1898            restyle_writes(&changes),
1899            vec![
1900                (1, "bg", SEG_SELECTED_BG_COLOR),
1901                (1, "text", SEG_SELECTED_TEXT),
1902                (2, "bg", SEG_UNSELECTED_BG_COLOR),
1903                (2, "text", SEG_UNSELECTED_TEXT),
1904            ],
1905            "selection feedback must not depend on the user wiring a callback"
1906        );
1907    }
1908
1909    #[test]
1910    fn click_on_a_single_segment_control_stays_at_zero() {
1911        let (styled, state) = flatten(Segmented::create(labels(&["only"])));
1912        let mut probe = state.clone();
1913        let (update, changes) = run_click(Some(styled), 1, state);
1914
1915        assert_eq!(update, Update::DoNothing);
1916        assert_eq!(selected_index_of(&mut probe), 0);
1917        assert_eq!(
1918            restyle_writes(&changes),
1919            vec![(1, "bg", SEG_SELECTED_BG_COLOR), (1, "text", SEG_SELECTED_TEXT)]
1920        );
1921    }
1922
1923    #[test]
1924    fn click_on_the_root_node_does_nothing() {
1925        // The container has no parent -> the handler must bail, not index into nothing.
1926        let (styled, state) = flatten(Segmented::create(labels(&["a", "b"])));
1927        let mut probe = state.clone();
1928
1929        let (update, changes) = run_click(Some(styled), 0, state);
1930
1931        assert_eq!(update, Update::DoNothing);
1932        assert!(changes.is_empty(), "nothing may be restyled when the click is not on a segment");
1933        assert_eq!(selected_index_of(&mut probe), 0, "state must be untouched");
1934    }
1935
1936    #[test]
1937    fn click_on_an_out_of_range_node_does_nothing() {
1938        let (styled, state) = flatten(Segmented::create(labels(&["a", "b"])));
1939        let mut probe = state.clone();
1940
1941        let (update, changes) = run_click(Some(styled), 9999, state);
1942
1943        assert_eq!(update, Update::DoNothing, "a hit node outside the tree must not panic");
1944        assert!(changes.is_empty());
1945        assert_eq!(selected_index_of(&mut probe), 0);
1946    }
1947
1948    #[test]
1949    fn click_with_no_layout_result_does_nothing() {
1950        let dom = Segmented::create(labels(&["a", "b"])).dom();
1951        let state = segment_state(&dom, 0);
1952
1953        let (update, changes) = run_click(None, 1, state);
1954
1955        assert_eq!(update, Update::DoNothing, "an empty LayoutWindow must be handled, not unwrapped");
1956        assert!(changes.is_empty());
1957    }
1958
1959    #[test]
1960    fn click_with_a_foreign_payload_does_nothing() {
1961        // Wrong type in the RefAny: the downcast fails, so the handler must bail
1962        // *before* restyling — otherwise the DOM would show a selection the state
1963        // never recorded.
1964        let (styled, _) = flatten(Segmented::create(labels(&["a", "b"])));
1965        let (update, changes) = run_click(Some(styled), 1, RefAny::new(0u32));
1966
1967        assert_eq!(update, Update::DoNothing);
1968        assert!(changes.is_empty(), "a failed downcast must not leave a half-applied restyle");
1969    }
1970
1971    #[test]
1972    fn click_with_the_state_already_borrowed_does_nothing() {
1973        let (styled, state) = flatten(Segmented::create(labels(&["a", "b"])));
1974
1975        // A live mutable borrow on a sibling clone: `downcast_mut` inside the
1976        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
1977        let mut held = state.clone();
1978        let guard = held.downcast_mut::<SegmentedStateWrapper>().expect("first borrow succeeds");
1979
1980        let (update, changes) = run_click(Some(styled), 1, state);
1981
1982        assert_eq!(update, Update::DoNothing);
1983        assert!(changes.is_empty());
1984        drop(guard);
1985    }
1986
1987    #[test]
1988    fn click_holds_the_state_borrow_across_the_user_callback() {
1989        // The handler invokes the user callback while its own `downcast_mut` on
1990        // the state is still live. A user callback that re-enters the *same*
1991        // state `RefAny` is therefore refused — it must get `None` back rather
1992        // than a second aliasing borrow (or a panic).
1993        //
1994        // NOTE: probe <-> state form a RefAny reference cycle, so this fixture
1995        // leaks. That is deliberate and harmless for a single test.
1996        let mut probe = RefAny::new(ReentrantProbe {
1997            state: RefAny::new(0u8),
1998            saw_index: Some(usize::MAX),
1999            calls: 0,
2000        });
2001        let state = RefAny::new(SegmentedStateWrapper {
2002            inner: SegmentedState { selected_index: 0 },
2003            on_change: Some(SegmentedOnChange {
2004                callback: change_cb(probe_state_reentrantly),
2005                refany: probe.clone(),
2006            })
2007            .into(),
2008        });
2009        {
2010            let mut p = probe.downcast_mut::<ReentrantProbe>().expect("ReentrantProbe");
2011            p.state = state.clone();
2012        }
2013
2014        let styled = StyledDom::create_from_dom(Segmented::create(labels(&["a", "b"])).dom());
2015        let (update, changes) = run_click(Some(styled), 2, state.clone());
2016
2017        assert_eq!(update, Update::DoNothing);
2018        assert_eq!(restyle_writes(&changes).len(), 4, "the restyle must still run afterwards");
2019
2020        let p = probe.downcast_ref::<ReentrantProbe>().expect("ReentrantProbe");
2021        assert_eq!(p.calls, 1, "the user callback must have run exactly once");
2022        assert_eq!(p.saw_index, None, "a re-entrant read of the state must be refused, not aliased");
2023    }
2024
2025    #[test]
2026    fn click_indices_stay_within_the_label_count() {
2027        // The index is derived from the sibling position, so it can never address
2028        // past the last rendered segment however many there are.
2029        let n = 128;
2030        let (styled, state) = flatten(Segmented::create(n_labels(n)));
2031
2032        for hit in [1usize, 2, n / 2, n - 1, n] {
2033            let mut probe = state.clone();
2034            let (_, changes) = run_click(Some(styled.clone()), hit, state.clone());
2035            let idx = selected_index_of(&mut probe);
2036            assert_eq!(idx, hit - 1, "node {hit} sits at sibling position {}", hit - 1);
2037            assert!(idx < n, "the reported index must always address a real label");
2038            assert_eq!(restyle_writes(&changes).len(), 2 * n);
2039        }
2040    }
2041
2042    #[test]
2043    fn click_recovers_a_state_left_out_of_range_by_the_setter() {
2044        // `set_selected_index(usize::MAX)` renders nothing selected; the first
2045        // click must snap the state back to a real, in-range segment.
2046        let seg = Segmented::create(n_labels(3)).with_selected_index(usize::MAX);
2047        let (styled, state) = flatten(seg);
2048        let mut probe = state.clone();
2049
2050        let (_, changes) = run_click(Some(styled), 2, state);
2051
2052        assert_eq!(selected_index_of(&mut probe), 1);
2053        assert_eq!(
2054            restyle_writes(&changes),
2055            vec![
2056                (1, "bg", SEG_UNSELECTED_BG_COLOR),
2057                (1, "text", SEG_UNSELECTED_TEXT),
2058                (2, "bg", SEG_SELECTED_BG_COLOR),
2059                (2, "text", SEG_SELECTED_TEXT),
2060                (3, "bg", SEG_UNSELECTED_BG_COLOR),
2061                (3, "text", SEG_UNSELECTED_TEXT),
2062            ]
2063        );
2064    }
2065}