1
//! Breadcrumb widget — a horizontal path-navigation trail: a row of clickable
2
//! crumb links separated by a "/" glyph, where the last crumb is the current
3
//! page (non-clickable, muted + bold). A blend of [`crate::widgets::segmented::Segmented`]
4
//! (the horizontal row of clickable text nodes whose clicked index is derived
5
//! from sibling position) and [`crate::widgets::button::Button`]'s `Link` look
6
//! (blue, pointer cursor) for the crumb links.
7
//!
8
//! Clicking a crumb invokes the user's `on_navigate(index)` carrying the clicked
9
//! crumb's index in [`BreadcrumbState`] (exactly how segmented carries its
10
//! selected index). Unlike segmented there is no persistent selection to
11
//! re-style: navigating a breadcrumb is expected to rebuild the page, so the
12
//! handler only reports the index and does not live-restyle.
13
//!
14
//! Index derivation: the children alternate `crumb, separator, crumb, separator,
15
//! …, current`, so crumb `i` sits at sibling position `2*i`; the handler computes
16
//! `index = position / 2`. Separators and the final current crumb carry no
17
//! callback, so the hit node is always a clickable crumb (an even position).
18
//!
19
//! Key types: [`Breadcrumb`], [`BreadcrumbState`], [`BreadcrumbOnNavigate`].
20

            
21
use std::vec::Vec;
22

            
23
use azul_core::{
24
    callbacks::{CoreCallbackData, Update},
25
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26
    refany::RefAny,
27
};
28
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
29
use azul_css::{
30
    props::{
31
        basic::{color::ColorU, StyleFontSize, StyleFontWeight},
32
        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutMarginLeft, LayoutMarginRight},
33
        property::{CssProperty, *},
34
        style::{StyleCursor, StyleUserSelect, StyleTextColor},
35
    },
36
    impl_option_inner, AzString, StringVec,
37
};
38

            
39
use crate::callbacks::{Callback, CallbackInfo};
40

            
41
static BREADCRUMB_CLASS: &[IdOrClass] =
42
    &[Class(AzString::from_const_str("__azul-native-breadcrumb"))];
43
static BREADCRUMB_ITEM_CLASS: &[IdOrClass] =
44
    &[Class(AzString::from_const_str("__azul-native-breadcrumb-item"))];
45
static BREADCRUMB_CURRENT_CLASS: &[IdOrClass] =
46
    &[Class(AzString::from_const_str("__azul-native-breadcrumb-current"))];
47
static BREADCRUMB_SEPARATOR_CLASS: &[IdOrClass] =
48
    &[Class(AzString::from_const_str("__azul-native-breadcrumb-separator"))];
49

            
50
/// Separator glyph rendered between crumbs.
51
const SEPARATOR_GLYPH: AzString = AzString::from_const_str("/");
52

            
53
/// Callback function type invoked when a (non-current) crumb is clicked.
54
pub type BreadcrumbOnNavigateCallbackType =
55
    extern "C" fn(RefAny, CallbackInfo, BreadcrumbState) -> Update;
56
impl_widget_callback!(
57
    BreadcrumbOnNavigate,
58
    OptionBreadcrumbOnNavigate,
59
    BreadcrumbOnNavigateCallback,
60
    BreadcrumbOnNavigateCallbackType
61
);
62

            
63
azul_core::impl_managed_callback! {
64
    wrapper:        BreadcrumbOnNavigateCallback,
65
    info_ty:        CallbackInfo,
66
    return_ty:      Update,
67
    default_ret:    Update::DoNothing,
68
    invoker_static: BREADCRUMB_ON_NAVIGATE_INVOKER,
69
    invoker_ty:     AzBreadcrumbOnNavigateCallbackInvoker,
70
    thunk_fn:       az_breadcrumb_on_navigate_callback_thunk,
71
    setter_fn:      AzApp_setBreadcrumbOnNavigateCallbackInvoker,
72
    from_handle_fn: AzBreadcrumbOnNavigateCallback_createFromHostHandle,
73
    extra_args:     [ state: BreadcrumbState ],
74
}
75

            
76
/// A horizontal trail of clickable crumb links ending in the current page.
77
#[derive(Debug, Clone, PartialEq, Eq)]
78
#[repr(C)]
79
pub struct Breadcrumb {
80
    pub breadcrumb_state: BreadcrumbStateWrapper,
81
    /// The crumb labels, in order (the last is the current, non-clickable page).
82
    pub labels: StringVec,
83
    /// Style for the row container.
84
    pub container_style: CssPropertyWithConditionsVec,
85
}
86

            
87
#[derive(Debug, Default, Clone, PartialEq, Eq)]
88
#[repr(C)]
89
pub struct BreadcrumbStateWrapper {
90
    /// The last-clicked crumb index.
91
    pub inner: BreadcrumbState,
92
    /// Optional: function to call when a crumb is clicked.
93
    pub on_navigate: OptionBreadcrumbOnNavigate,
94
}
95

            
96
/// State of a [`Breadcrumb`]: the index of the most recently clicked crumb.
97
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
98
#[repr(C)]
99
pub struct BreadcrumbState {
100
    /// Zero-based index of the clicked crumb.
101
    pub selected_index: usize,
102
}
103

            
104
// ---- colours ----
105
/// Crumb-link colour (#0d6efd, Bootstrap link blue).
106
const LINK_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
107
/// Current-crumb colour (#495057, muted dark grey).
108
const CURRENT_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
109
/// Separator colour (#6c757d, grey).
110
const SEPARATOR_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
111

            
112
/// Row container: a horizontal flex row that hugs its content.
113
static BREADCRUMB_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
114
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
115
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
116
    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
117
    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
118
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
119
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
120
];
121

            
122
/// Clickable crumb-link style (blue, pointer cursor). A hover underline is
123
/// omitted to keep the style a const slice (`TextDecoration::Underline.into()`
124
/// is not const); the link colour + pointer already read clearly as a link.
125
static BREADCRUMB_ITEM_STYLE: &[CssPropertyWithConditions] = &[
126
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
127
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
128
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
129
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
130
        inner: LINK_COLOR,
131
    })),
132
];
133

            
134
/// Current (last) crumb style: muted dark, bold, not clickable.
135
static BREADCRUMB_CURRENT_STYLE: &[CssPropertyWithConditions] = &[
136
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
137
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
138
    CssPropertyWithConditions::simple(CssProperty::font_weight(StyleFontWeight::Bold)),
139
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
140
        inner: CURRENT_COLOR,
141
    })),
142
];
143

            
144
/// Separator-glyph style: grey, with a small horizontal gap on each side.
145
static BREADCRUMB_SEPARATOR_STYLE: &[CssPropertyWithConditions] = &[
146
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
147
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
148
    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))),
149
    CssPropertyWithConditions::simple(CssProperty::const_margin_right(LayoutMarginRight::const_px(
150
        8,
151
    ))),
152
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
153
        inner: SEPARATOR_COLOR,
154
    })),
155
];
156

            
157
impl Breadcrumb {
158
    /// Creates a breadcrumb from the given labels (the last is the current page).
159
351
    #[must_use] pub fn create(labels: StringVec) -> Self {
160
351
        Self {
161
351
            breadcrumb_state: BreadcrumbStateWrapper::default(),
162
351
            labels,
163
351
            container_style: CssPropertyWithConditionsVec::from_const_slice(
164
351
                BREADCRUMB_CONTAINER_STYLE,
165
351
            ),
166
351
        }
167
351
    }
168

            
169
    #[inline]
170
104
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
171
104
        let mut s = Self::create(StringVec::from_const_slice(&[]));
172
104
        core::mem::swap(&mut s, self);
173
104
        s
174
104
    }
175

            
176
    #[inline]
177
8
    pub fn set_on_navigate<C: Into<BreadcrumbOnNavigateCallback>>(
178
8
        &mut self,
179
8
        data: RefAny,
180
8
        on_navigate: C,
181
8
    ) {
182
8
        self.breadcrumb_state.on_navigate = Some(BreadcrumbOnNavigate {
183
8
            callback: on_navigate.into(),
184
8
            refany: data,
185
8
        })
186
8
        .into();
187
8
    }
188

            
189
    #[inline]
190
6
    #[must_use] pub fn with_on_navigate<C: Into<BreadcrumbOnNavigateCallback>>(
191
6
        mut self,
192
6
        data: RefAny,
193
6
        on_navigate: C,
194
6
    ) -> Self {
195
6
        self.set_on_navigate(data, on_navigate);
196
6
        self
197
6
    }
198

            
199
31
    #[must_use] pub fn dom(self) -> Dom {
200
        use azul_core::{
201
            callbacks::CoreCallback,
202
            dom::{EventFilter, HoverEventFilter},
203
            refany::OptionRefAny,
204
        };
205

            
206
31
        let count = self.labels.as_ref().len();
207

            
208
        // One shared RefAny across every crumb callback (RefAny::clone shares the
209
        // underlying state — same pattern as segmented/tabs/map).
210
31
        let state = RefAny::new(self.breadcrumb_state);
211

            
212
31
        let mut children: Vec<Dom> = Vec::with_capacity(count.saturating_mul(2));
213
655
        for (i, label) in self.labels.as_ref().iter().enumerate() {
214
655
            let is_last = i + 1 == count;
215

            
216
655
            if is_last {
217
28
                // The current page: muted + bold, non-clickable (no callback).
218
28
                children.push(
219
28
                    Dom::create_p_with_text(label.clone())
220
28
                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
221
28
                            BREADCRUMB_CURRENT_CLASS,
222
28
                        ))
223
28
                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
224
28
                            BREADCRUMB_CURRENT_STYLE,
225
28
                        )),
226
28
                );
227
627
            } else {
228
627
                // A clickable crumb link.
229
627
                children.push(
230
627
                    Dom::create_p_with_text(label.clone())
231
627
                        .with_ids_and_classes(IdOrClassVec::from_const_slice(BREADCRUMB_ITEM_CLASS))
232
627
                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
233
627
                            BREADCRUMB_ITEM_STYLE,
234
627
                        ))
235
627
                        .with_callbacks(
236
627
                            vec![CoreCallbackData {
237
627
                                event: EventFilter::Hover(HoverEventFilter::MouseUp),
238
627
                                callback: CoreCallback {
239
627
                                    cb: on_crumb_click as usize,
240
627
                                    ctx: OptionRefAny::None,
241
627
                                },
242
627
                                refany: state.clone(),
243
627
                            }]
244
627
                            .into(),
245
627
                        )
246
627
                        .with_tab_index(TabIndex::Auto),
247
627
                );
248
627
                // Separator after every non-last crumb.
249
627
                children.push(
250
627
                    Dom::create_p_with_text(SEPARATOR_GLYPH)
251
627
                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
252
627
                            BREADCRUMB_SEPARATOR_CLASS,
253
627
                        ))
254
627
                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
255
627
                            BREADCRUMB_SEPARATOR_STYLE,
256
627
                        )),
257
627
                );
258
627
            }
259
        }
260

            
261
31
        Dom::create_div()
262
31
            .with_ids_and_classes(IdOrClassVec::from_const_slice(BREADCRUMB_CLASS))
263
31
            .with_css_props(self.container_style)
264
31
            .with_children(children.into())
265
31
    }
266
}
267

            
268
impl Default for Breadcrumb {
269
203
    fn default() -> Self {
270
203
        Self::create(StringVec::from_const_slice(&[]))
271
203
    }
272
}
273

            
274
/// Click handler shared by all crumb links. Determines the clicked crumb's index
275
/// from its position among its siblings (`index = position / 2`, since the
276
/// children alternate crumb/separator), updates the state, and invokes the user
277
/// `on_navigate` callback. No live restyle — navigating is expected to rebuild
278
/// the page.
279
17
extern "C" fn on_crumb_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
280
    use azul_core::dom::DomNodeId;
281

            
282
17
    let clicked = info.get_hit_node();
283
17
    let Some(parent) = info.get_parent(clicked) else {
284
3
        return Update::DoNothing;
285
    };
286

            
287
    // Collect the children in document order, then find the clicked crumb's slot.
288
14
    let mut siblings: Vec<DomNodeId> = Vec::new();
289
14
    let mut cur = info.get_first_child(parent);
290
216
    while let Some(node) = cur {
291
202
        siblings.push(node);
292
202
        cur = info.get_next_sibling(node);
293
202
    }
294

            
295
163
    let Some(pos) = siblings.iter().position(|n| *n == clicked) else {
296
        return Update::DoNothing;
297
    };
298
    // Crumbs sit at even positions (crumb, separator, crumb, separator, …).
299
14
    let index = pos / 2;
300

            
301
14
    let Some(mut bc) = data.downcast_mut::<BreadcrumbStateWrapper>() else {
302
2
        return Update::DoNothing;
303
    };
304
12
    bc.inner.selected_index = index;
305
12
    let inner = bc.inner;
306
12
    let bc = &mut *bc;
307
12
    match bc.on_navigate.as_mut() {
308
4
        Some(BreadcrumbOnNavigate { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
309
8
        None => Update::DoNothing,
310
    }
311
17
}
312

            
313
impl From<Breadcrumb> for Dom {
314
    fn from(b: Breadcrumb) -> Self {
315
        b.dom()
316
    }
317
}
318

            
319
#[cfg(test)]
320
mod autotest_generated {
321
    use std::{
322
        collections::{BTreeMap, HashMap},
323
        sync::{Arc, Mutex},
324
    };
325

            
326
    use azul_core::{
327
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
328
        geom::{LogicalRect, OptionLogicalPosition},
329
        gl::OptionGlContextPtr,
330
        hit_test::ScrollPosition,
331
        refany::OptionRefAny,
332
        resources::RendererResources,
333
        styled_dom::{NodeHierarchyItemId, StyledDom},
334
        window::{MonitorVec, RawWindowHandle},
335
    };
336
    use azul_css::system::SystemStyle;
337
    use rust_fontconfig::FcFontCache;
338

            
339
    use super::*;
340
    #[cfg(feature = "icu")]
341
    use crate::icu::IcuLocalizerHandle;
342
    use crate::{
343
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
344
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
345
        window::{DomLayoutResult, LayoutWindow},
346
        window_state::FullWindowState,
347
    };
348

            
349
    // ------------------------------------------------------------------
350
    // Helpers
351
    // ------------------------------------------------------------------
352

            
353
    fn labels(v: &[&str]) -> StringVec {
354
        StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
355
    }
356

            
357
    /// `n` distinct labels: `c0, c1, … c{n-1}`.
358
    fn n_labels(n: usize) -> StringVec {
359
        StringVec::from_vec((0..n).map(|i| AzString::from(format!("c{i}"))).collect::<Vec<_>>())
360
    }
361

            
362
    /// The text of a text node, looking through the `<p>` block wrapper the
363
    /// label convention mandates (`p > text`).
364
    fn text_of(node: &Dom) -> Option<&str> {
365
        match node.root.get_node_type() {
366
            NodeType::Text(s) => Some(s.as_ref().as_str()),
367
            NodeType::P => match node.children.as_ref() {
368
                [only] => match only.root.get_node_type() {
369
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
370
                    _ => None,
371
                },
372
                _ => None,
373
            },
374
            _ => None,
375
        }
376
    }
377

            
378
    /// The true recursive descendant count of a `Dom` — what
379
    /// `estimated_total_children` is documented to cache.
380
    fn recursive_descendants(node: &Dom) -> usize {
381
        node.children
382
            .as_ref()
383
            .iter()
384
            .map(|c| 1 + recursive_descendants(c))
385
            .sum()
386
    }
387

            
388
    /// The `color` (text colour) declared by a style slice.
389
    fn text_color(style: &[CssPropertyWithConditions]) -> Option<ColorU> {
390
        style.iter().find_map(|p| match &p.property {
391
            CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
392
            _ => None,
393
        })
394
    }
395

            
396
    fn has_property(style: &[CssPropertyWithConditions], wanted: &CssProperty) -> bool {
397
        style.iter().any(|p| p.property == *wanted)
398
    }
399

            
400
    fn has_cursor(style: &[CssPropertyWithConditions]) -> bool {
401
        style
402
            .iter()
403
            .any(|p| matches!(&p.property, CssProperty::Cursor(_)))
404
    }
405

            
406
    /// A `RefAny` payload recording every index a user `on_navigate` sees.
407
    struct NavLog {
408
        seen: Vec<usize>,
409
    }
410

            
411
    extern "C" fn record_nav(mut data: RefAny, _: CallbackInfo, state: BreadcrumbState) -> Update {
412
        if let Some(mut log) = data.downcast_mut::<NavLog>() {
413
            log.seen.push(state.selected_index);
414
        }
415
        Update::RefreshDom
416
    }
417

            
418
    extern "C" fn nav_do_nothing(_: RefAny, _: CallbackInfo, _: BreadcrumbState) -> Update {
419
        Update::DoNothing
420
    }
421

            
422
    extern "C" fn nav_refresh_all(_: RefAny, _: CallbackInfo, _: BreadcrumbState) -> Update {
423
        Update::RefreshDomAllWindows
424
    }
425

            
426
    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
427
    fn nav_cb(f: BreadcrumbOnNavigateCallbackType) -> BreadcrumbOnNavigateCallback {
428
        f.into()
429
    }
430

            
431
    fn log_indices(data: &mut RefAny) -> Vec<usize> {
432
        data.downcast_ref::<NavLog>()
433
            .expect("payload must still be a NavLog")
434
            .seen
435
            .clone()
436
    }
437

            
438
    fn selected_index_of(data: &mut RefAny) -> usize {
439
        data.downcast_ref::<BreadcrumbStateWrapper>()
440
            .expect("payload must still be a BreadcrumbStateWrapper")
441
            .inner
442
            .selected_index
443
    }
444

            
445
    /// The `RefAny` carried by crumb `i`'s click callback (crumbs sit at even
446
    /// child positions).
447
    fn crumb_state(dom: &Dom, crumb: usize) -> RefAny {
448
        let cbs = dom.children.as_ref()[crumb * 2].root.get_callbacks();
449
        cbs.as_ref()
450
            .first()
451
            .expect("a non-last crumb must carry the click callback")
452
            .refany
453
            .clone()
454
    }
455

            
456
    /// A `DomLayoutResult` with an *empty* layout tree: `on_crumb_click` only
457
    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
458
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
459
        DomLayoutResult {
460
            styled_dom,
461
            layout_tree: LayoutTree {
462
                nodes: Vec::new(),
463
                warm: Vec::new(),
464
                cold: Vec::new(),
465
                root: 0,
466
                dom_to_layout: BTreeMap::new(),
467
                children_arena: Vec::new(),
468
                children_offsets: Vec::new(),
469
                subtree_needs_intrinsic: Vec::new(),
470
            },
471
            calculated_positions: Vec::new(),
472
            viewport: LogicalRect::zero(),
473
            display_list: Arc::new(DisplayList::default()),
474
            scroll_ids: HashMap::new(),
475
            scroll_id_to_node_id: HashMap::new(),
476
        }
477
    }
478

            
479
    /// Flattens `bc.dom()` and hands back the shared state `RefAny` the crumb
480
    /// callbacks carry. Requires >= 2 labels (so that crumb 0 is clickable).
481
    fn flatten(bc: Breadcrumb) -> (StyledDom, RefAny) {
482
        let dom = bc.dom();
483
        let state = crumb_state(&dom, 0);
484
        (StyledDom::create_from_dom(dom), state)
485
    }
486

            
487
    /// Invokes `on_crumb_click` against a `LayoutWindow` holding `styled` (or
488
    /// nothing at all, when `styled` is `None`), with node `hit` as the hit node.
489
    /// Returns the `Update` plus every recorded `CallbackChange`.
490
    fn run_click(
491
        styled: Option<StyledDom>,
492
        hit: usize,
493
        data: RefAny,
494
    ) -> (Update, Vec<CallbackChange>) {
495
        let mut layout_window =
496
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
497
        if let Some(sd) = styled {
498
            layout_window
499
                .layout_results
500
                .insert(DomId::ROOT_ID, layout_result(sd));
501
        }
502

            
503
        let renderer_resources = RendererResources::default();
504
        let previous_window_state: Option<FullWindowState> = None;
505
        let current_window_state = FullWindowState::default();
506
        let gl_context = OptionGlContextPtr::None;
507
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
508
            BTreeMap::new();
509
        let window_handle = RawWindowHandle::Unsupported;
510
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
511

            
512
        let ref_data = CallbackInfoRefData {
513
            layout_window: &layout_window,
514
            renderer_resources: &renderer_resources,
515
            previous_window_state: &previous_window_state,
516
            current_window_state: &current_window_state,
517
            gl_context: &gl_context,
518
            current_scroll_manager: &scroll_states,
519
            current_window_handle: &window_handle,
520
            system_callbacks: &system_callbacks,
521
            system_style: Arc::new(SystemStyle::default()),
522
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
523
            #[cfg(feature = "icu")]
524
            icu_localizer: IcuLocalizerHandle::default(),
525
            ctx: OptionRefAny::None,
526
        };
527

            
528
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
529

            
530
        let info = CallbackInfo::new(
531
            &ref_data,
532
            &changes,
533
            DomNodeId {
534
                dom: DomId::ROOT_ID,
535
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
536
            },
537
            OptionLogicalPosition::None,
538
            OptionLogicalPosition::None,
539
        );
540

            
541
        let update = on_crumb_click(data, info);
542
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
543
        (update, recorded)
544
    }
545

            
546
    // ------------------------------------------------------------------
547
    // Breadcrumb::create
548
    // ------------------------------------------------------------------
549

            
550
    #[test]
551
    fn create_preserves_labels_verbatim_and_defaults_the_state() {
552
        for case in [
553
            vec![],
554
            vec!["only"],
555
            vec!["Home", "Docs"],
556
            vec!["Home", "Docs", "Widgets", "Breadcrumb"],
557
        ] {
558
            let bc = Breadcrumb::create(labels(&case));
559

            
560
            let got: Vec<&str> = bc.labels.as_ref().iter().map(AzString::as_str).collect();
561
            assert_eq!(got, case, "create must not reorder/drop/rewrite labels");
562

            
563
            assert_eq!(
564
                bc.breadcrumb_state.inner.selected_index, 0,
565
                "a fresh breadcrumb starts at crumb 0"
566
            );
567
            assert!(
568
                bc.breadcrumb_state.on_navigate.as_ref().is_none(),
569
                "create must not install a callback"
570
            );
571
            assert_eq!(
572
                bc.container_style.as_ref(),
573
                BREADCRUMB_CONTAINER_STYLE,
574
                "create must use the shared const container style"
575
            );
576
        }
577
    }
578

            
579
    #[test]
580
    fn create_survives_pathological_labels() {
581
        // empty string, whitespace-only, a label that *is* the separator glyph,
582
        // emoji + ZWJ, RTL, combining marks, NUL, and a 100k-char label.
583
        let huge = "x".repeat(100_000);
584
        let case = vec![
585
            "",
586
            "   ",
587
            "/",
588
            "//",
589
            "a\u{0}b",
590
            "👨‍👩‍👧‍👦",
591
            "مرحبا",
592
            "e\u{0301}\u{0301}\u{0301}",
593
            "\u{200b}\u{feff}",
594
            huge.as_str(),
595
        ];
596
        let bc = Breadcrumb::create(labels(&case));
597

            
598
        let got: Vec<&str> = bc.labels.as_ref().iter().map(AzString::as_str).collect();
599
        assert_eq!(got, case, "labels must survive byte-for-byte");
600
        assert_eq!(bc.labels.as_ref()[9].as_str().len(), 100_000);
601

            
602
        // …and they must survive the trip through the DOM unchanged.
603
        let dom = bc.dom();
604
        let texts: Vec<&str> = dom
605
            .children
606
            .as_ref()
607
            .iter()
608
            .enumerate()
609
            .filter(|(i, _)| i % 2 == 0) // crumbs sit at even positions
610
            .filter_map(|(_, c)| text_of(c))
611
            .collect();
612
        assert_eq!(texts, case);
613
    }
614

            
615
    #[test]
616
    fn create_with_many_labels_does_not_panic() {
617
        let n = 10_000;
618
        let bc = Breadcrumb::create(n_labels(n));
619
        assert_eq!(bc.labels.as_ref().len(), n);
620
        assert_eq!(bc.labels.as_ref()[n - 1].as_str(), "c9999");
621
    }
622

            
623
    #[test]
624
    fn default_equals_create_with_no_labels() {
625
        assert_eq!(
626
            Breadcrumb::default(),
627
            Breadcrumb::create(StringVec::from_const_slice(&[]))
628
        );
629
        assert!(Breadcrumb::default().labels.as_ref().is_empty());
630
    }
631

            
632
    // ------------------------------------------------------------------
633
    // Breadcrumb::swap_with_default
634
    // ------------------------------------------------------------------
635

            
636
    #[test]
637
    fn swap_with_default_returns_the_old_value_and_resets_self() {
638
        let mut bc = Breadcrumb::create(labels(&["Home", "Docs", "Here"]));
639
        let old = bc.swap_with_default();
640

            
641
        let old_labels: Vec<&str> = old.labels.as_ref().iter().map(AzString::as_str).collect();
642
        assert_eq!(old_labels, ["Home", "Docs", "Here"], "the old value moves out");
643
        assert!(
644
            bc.labels.as_ref().is_empty(),
645
            "self must be left as a default (empty) breadcrumb"
646
        );
647
        assert_eq!(bc, Breadcrumb::default());
648
    }
649

            
650
    #[test]
651
    fn swap_with_default_moves_the_callback_out_of_self() {
652
        let mut bc = Breadcrumb::create(labels(&["a", "b"]))
653
            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
654

            
655
        let old = bc.swap_with_default();
656
        assert!(
657
            old.breadcrumb_state.on_navigate.as_ref().is_some(),
658
            "the callback must travel with the returned value"
659
        );
660
        assert!(
661
            bc.breadcrumb_state.on_navigate.as_ref().is_none(),
662
            "self must not keep a dangling reference to the moved-out callback"
663
        );
664
    }
665

            
666
    #[test]
667
    fn swap_with_default_is_stable_when_repeated() {
668
        let mut bc = Breadcrumb::create(labels(&["a"]));
669
        let _ = bc.swap_with_default();
670
        // Now `bc` is already a default — swapping again must keep returning
671
        // defaults, not panic or corrupt state.
672
        for _ in 0..100 {
673
            let out = bc.swap_with_default();
674
            assert_eq!(out, Breadcrumb::default());
675
            assert_eq!(bc, Breadcrumb::default());
676
        }
677
    }
678

            
679
    #[test]
680
    fn swap_with_default_preserves_a_customised_state() {
681
        let mut bc = Breadcrumb::create(labels(&["a", "b"]));
682
        bc.breadcrumb_state.inner.selected_index = usize::MAX;
683

            
684
        let old = bc.swap_with_default();
685
        assert_eq!(
686
            old.breadcrumb_state.inner.selected_index,
687
            usize::MAX,
688
            "an out-of-range index must move out untouched (no clamping/rewrite)"
689
        );
690
        assert_eq!(bc.breadcrumb_state.inner.selected_index, 0);
691
    }
692

            
693
    // ------------------------------------------------------------------
694
    // Breadcrumb::set_on_navigate / with_on_navigate
695
    // ------------------------------------------------------------------
696

            
697
    #[test]
698
    fn with_on_navigate_sets_the_callback_and_touches_nothing_else() {
699
        let before = Breadcrumb::create(labels(&["Home", "Docs"]));
700
        let after = Breadcrumb::create(labels(&["Home", "Docs"]))
701
            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
702

            
703
        assert_eq!(
704
            after.labels, before.labels,
705
            "installing a callback must not disturb the labels"
706
        );
707
        assert_eq!(
708
            after.container_style, before.container_style,
709
            "installing a callback must not disturb the container style"
710
        );
711
        assert_eq!(after.breadcrumb_state.inner.selected_index, 0);
712

            
713
        let installed = after
714
            .breadcrumb_state
715
            .on_navigate
716
            .as_ref()
717
            .expect("with_on_navigate must install Some(..)");
718
        assert_eq!(installed.callback.cb as usize, record_nav as usize);
719
    }
720

            
721
    #[test]
722
    fn set_on_navigate_overwrites_the_previous_callback_and_data() {
723
        let mut bc = Breadcrumb::create(labels(&["a", "b"]));
724
        bc.set_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
725
        bc.set_on_navigate(RefAny::new(42u32), nav_cb(nav_do_nothing));
726

            
727
        let installed = bc
728
            .breadcrumb_state
729
            .on_navigate
730
            .as_mut()
731
            .expect("still Some after the overwrite");
732
        assert_eq!(
733
            installed.callback.cb as usize,
734
            nav_do_nothing as usize,
735
            "the last set_on_navigate must win"
736
        );
737
        assert_eq!(
738
            installed.refany.downcast_ref::<u32>().map(|v| *v),
739
            Some(42),
740
            "the payload must be replaced along with the fn pointer"
741
        );
742
        assert!(
743
            installed.refany.downcast_ref::<NavLog>().is_none(),
744
            "the stale payload must be gone"
745
        );
746
    }
747

            
748
    #[test]
749
    fn set_on_navigate_accepts_a_generic_callback_without_corrupting_the_fn_pointer() {
750
        // The FFI path (`From<Callback>`) transmutes the fn pointer. The value
751
        // must round-trip bit-for-bit — a corrupted pointer would be an
752
        // unconditional jump into garbage at click time. (Never invoked here.)
753
        let raw = record_nav as usize;
754
        let generic = Callback {
755
            cb: unsafe { core::mem::transmute::<usize, crate::callbacks::CallbackType>(raw) },
756
            ctx: OptionRefAny::None,
757
        };
758
        let converted: BreadcrumbOnNavigateCallback = generic.into();
759
        assert_eq!(converted.cb as usize, raw);
760
    }
761

            
762
    #[test]
763
    fn with_on_navigate_on_an_empty_breadcrumb_yields_a_dom_with_no_callbacks() {
764
        // 0 labels => no crumbs => the installed callback is simply never wired up.
765
        let dom = Breadcrumb::create(StringVec::from_const_slice(&[]))
766
            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav))
767
            .dom();
768

            
769
        assert!(dom.children.as_ref().is_empty());
770
        assert!(dom.root.get_callbacks().as_ref().is_empty());
771
    }
772

            
773
    // ------------------------------------------------------------------
774
    // Breadcrumb::dom
775
    // ------------------------------------------------------------------
776

            
777
    #[test]
778
    fn dom_of_no_labels_is_an_empty_container() {
779
        let dom = Breadcrumb::create(StringVec::from_const_slice(&[])).dom();
780

            
781
        assert!(
782
            dom.children.as_ref().is_empty(),
783
            "no labels => no crumbs and no separators"
784
        );
785
        assert_eq!(dom.estimated_total_children, 0);
786
        assert!(dom.root.has_class("__azul-native-breadcrumb"));
787
    }
788

            
789
    #[test]
790
    fn dom_of_a_single_label_has_no_separator_and_no_clickable_crumb() {
791
        let dom = Breadcrumb::create(labels(&["Home"])).dom();
792
        let children = dom.children.as_ref();
793

            
794
        assert_eq!(children.len(), 1, "a lone label is the current page, nothing else");
795
        assert_eq!(text_of(&children[0]), Some("Home"));
796
        assert!(children[0].root.has_class("__azul-native-breadcrumb-current"));
797
        assert!(
798
            children[0].root.get_callbacks().as_ref().is_empty(),
799
            "the current page must not be clickable"
800
        );
801
        assert_eq!(children[0].root.get_tab_index(), None);
802
    }
803

            
804
    #[test]
805
    fn dom_alternates_crumb_separator_and_ends_on_the_current_page() {
806
        let case = ["Home", "Docs", "Widgets", "Breadcrumb"];
807
        let dom = Breadcrumb::create(labels(&case)).dom();
808
        let children = dom.children.as_ref();
809

            
810
        assert_eq!(children.len(), 2 * case.len() - 1, "n crumbs + (n-1) separators");
811

            
812
        for (pos, child) in children.iter().enumerate() {
813
            let is_last_child = pos + 1 == children.len();
814
            if pos % 2 == 1 {
815
                // separator
816
                assert_eq!(text_of(child), Some("/"), "odd positions are separators");
817
                assert!(child.root.has_class("__azul-native-breadcrumb-separator"));
818
                assert!(
819
                    child.root.get_callbacks().as_ref().is_empty(),
820
                    "separators must not be clickable"
821
                );
822
                assert_eq!(child.root.get_tab_index(), None);
823
            } else if is_last_child {
824
                // current page
825
                assert_eq!(text_of(child), Some(case[pos / 2]));
826
                assert!(child.root.has_class("__azul-native-breadcrumb-current"));
827
                assert!(
828
                    child.root.get_callbacks().as_ref().is_empty(),
829
                    "the current page must not be clickable"
830
                );
831
                assert_eq!(child.root.get_tab_index(), None);
832
            } else {
833
                // clickable crumb link
834
                assert_eq!(text_of(child), Some(case[pos / 2]));
835
                assert!(child.root.has_class("__azul-native-breadcrumb-item"));
836
                let cbs = child.root.get_callbacks();
837
                assert_eq!(cbs.as_ref().len(), 1, "one MouseUp handler per crumb");
838
                assert_eq!(
839
                    cbs.as_ref()[0].event,
840
                    EventFilter::Hover(HoverEventFilter::MouseUp)
841
                );
842
                assert_eq!(cbs.as_ref()[0].callback.cb, on_crumb_click as usize);
843
                assert_eq!(
844
                    child.root.get_tab_index(),
845
                    Some(TabIndex::Auto),
846
                    "crumb links must be keyboard-reachable"
847
                );
848
            }
849
        }
850
    }
851

            
852
    #[test]
853
    fn dom_estimated_total_children_matches_the_real_descendant_count() {
854
        // `estimated_total_children` is a cached count; if it under-counts,
855
        // `convert_dom_into_compact_dom` under-allocates and panics.
856
        for n in [0usize, 1, 2, 3, 5, 64, 257] {
857
            let dom = Breadcrumb::create(n_labels(n)).dom();
858
            let children = if n == 0 { 0 } else { 2 * n - 1 };
859
            // Every child is a styled `<p>` wrapping its bare text leaf per
860
            // the label convention, so descendants = 2 x children.
861
            let expected = 2 * children;
862

            
863
            assert_eq!(dom.children.as_ref().len(), children, "child count for n={n}");
864
            assert_eq!(
865
                dom.estimated_total_children,
866
                recursive_descendants(&dom),
867
                "cached descendant count desynced for n={n}"
868
            );
869
            assert_eq!(dom.estimated_total_children, expected, "for n={n}");
870
        }
871
    }
872

            
873
    #[test]
874
    fn dom_of_many_labels_flattens_without_panicking() {
875
        let n = 200;
876
        let styled = StyledDom::create_from_dom(Breadcrumb::create(n_labels(n)).dom());
877
        assert_eq!(
878
            styled.node_hierarchy.as_ref().len(),
879
            4 * n - 1,
880
            "root + (2n-1) children, each a <p> + its text leaf"
881
        );
882
    }
883

            
884
    #[test]
885
    fn dom_separator_is_told_apart_by_class_not_by_text() {
886
        // A label that is literally "/" must still be a crumb, not a separator.
887
        let dom = Breadcrumb::create(labels(&["/", "b"])).dom();
888
        let children = dom.children.as_ref();
889

            
890
        assert_eq!(text_of(&children[0]), Some("/"));
891
        assert!(children[0].root.has_class("__azul-native-breadcrumb-item"));
892
        assert!(!children[0].root.has_class("__azul-native-breadcrumb-separator"));
893
        assert!(!children[0].root.get_callbacks().as_ref().is_empty());
894

            
895
        assert_eq!(text_of(&children[1]), Some("/"));
896
        assert!(children[1].root.has_class("__azul-native-breadcrumb-separator"));
897
        assert!(children[1].root.get_callbacks().as_ref().is_empty());
898
    }
899

            
900
    #[test]
901
    fn dom_shares_one_state_refany_across_every_crumb() {
902
        let dom = Breadcrumb::create(labels(&["a", "b", "c", "d"])).dom();
903

            
904
        // Write through crumb 0's handle…
905
        let mut first = crumb_state(&dom, 0);
906
        {
907
            let mut w = first
908
                .downcast_mut::<BreadcrumbStateWrapper>()
909
                .expect("crumb state must be a BreadcrumbStateWrapper");
910
            w.inner.selected_index = 7;
911
        }
912
        // …and read it back through crumb 2's handle.
913
        let mut third = crumb_state(&dom, 2);
914
        assert_eq!(
915
            selected_index_of(&mut third),
916
            7,
917
            "every crumb must observe the same shared state"
918
        );
919
    }
920

            
921
    #[test]
922
    fn dom_gives_separate_breadcrumbs_separate_state() {
923
        let a = Breadcrumb::create(labels(&["a", "b"])).dom();
924
        let b = Breadcrumb::create(labels(&["a", "b"])).dom();
925

            
926
        let mut a0 = crumb_state(&a, 0);
927
        {
928
            let mut w = a0.downcast_mut::<BreadcrumbStateWrapper>().unwrap();
929
            w.inner.selected_index = 3;
930
        }
931

            
932
        let mut b0 = crumb_state(&b, 0);
933
        assert_eq!(
934
            selected_index_of(&mut b0),
935
            0,
936
            "two breadcrumbs must not alias one another's state"
937
        );
938
    }
939

            
940
    #[test]
941
    fn dom_carries_the_container_style_through() {
942
        let bc = Breadcrumb::create(labels(&["a", "b"]));
943
        assert_eq!(bc.container_style.as_ref(), BREADCRUMB_CONTAINER_STYLE);
944

            
945
        let dom = bc.dom();
946
        assert!(dom.root.has_class("__azul-native-breadcrumb"));
947
        assert!(
948
            dom.root.is_node_type(NodeType::Div),
949
            "the row container must be a div"
950
        );
951
    }
952

            
953
    // ------------------------------------------------------------------
954
    // Style constants (invariants the widget's look depends on)
955
    // ------------------------------------------------------------------
956

            
957
    #[test]
958
    fn crumb_styles_are_opaque_and_visually_distinct() {
959
        for (name, c) in [
960
            ("link", LINK_COLOR),
961
            ("current", CURRENT_COLOR),
962
            ("separator", SEPARATOR_COLOR),
963
        ] {
964
            assert_eq!(c.a, 255, "{name} colour must be fully opaque");
965
        }
966
        assert_ne!(
967
            LINK_COLOR, CURRENT_COLOR,
968
            "the current page must not look like a link"
969
        );
970

            
971
        assert_eq!(text_color(BREADCRUMB_ITEM_STYLE), Some(LINK_COLOR));
972
        assert_eq!(text_color(BREADCRUMB_CURRENT_STYLE), Some(CURRENT_COLOR));
973
        assert_eq!(text_color(BREADCRUMB_SEPARATOR_STYLE), Some(SEPARATOR_COLOR));
974
    }
975

            
976
    #[test]
977
    fn only_the_clickable_crumb_style_declares_a_pointer_cursor() {
978
        assert!(has_property(
979
            BREADCRUMB_ITEM_STYLE,
980
            &CssProperty::const_cursor(StyleCursor::Pointer)
981
        ));
982
        assert!(
983
            !has_cursor(BREADCRUMB_CURRENT_STYLE),
984
            "the current page is not clickable, so it must not advertise a pointer"
985
        );
986
        assert!(
987
            !has_cursor(BREADCRUMB_SEPARATOR_STYLE),
988
            "separators are not clickable"
989
        );
990
    }
991

            
992
    #[test]
993
    fn separator_style_has_symmetric_horizontal_margins() {
994
        assert!(has_property(
995
            BREADCRUMB_SEPARATOR_STYLE,
996
            &CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))
997
        ));
998
        assert!(has_property(
999
            BREADCRUMB_SEPARATOR_STYLE,
            &CssProperty::const_margin_right(LayoutMarginRight::const_px(8))
        ));
    }
    #[test]
    fn every_crumb_style_disables_text_selection_and_flex_growth() {
        for (name, style) in [
            ("item", BREADCRUMB_ITEM_STYLE),
            ("current", BREADCRUMB_CURRENT_STYLE),
            ("separator", BREADCRUMB_SEPARATOR_STYLE),
        ] {
            assert!(
                has_property(style, &CssProperty::user_select(StyleUserSelect::None)),
                "{name}: dragging across a breadcrumb must not select its text"
            );
            assert!(
                has_property(
                    style,
                    &CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
                ),
                "{name}: crumbs must hug their content"
            );
        }
        assert!(has_property(
            BREADCRUMB_CURRENT_STYLE,
            &CssProperty::font_weight(StyleFontWeight::Bold)
        ));
        assert_eq!(SEPARATOR_GLYPH.as_str(), "/");
    }
    // ------------------------------------------------------------------
    // on_crumb_click
    // ------------------------------------------------------------------
    #[test]
    fn click_reports_the_index_of_each_crumb() {
        // Flat DFS with the label convention (<p> + text leaf per child):
        // crumb0(1) sep(3) crumb1(5) sep(7) crumb2(9) sep(11) current(13)
        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b", "c", "d"])));
        assert_eq!(
            styled.node_hierarchy.as_ref().len(),
            15,
            "fixture must flatten to root + 7 children + their 7 text leaves"
        );
        for (hit, expected) in [(1usize, 0usize), (5, 1), (9, 2)] {
            let mut state = state.clone();
            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
            assert_eq!(
                update,
                Update::DoNothing,
                "with no on_navigate installed the handler reports nothing to redraw"
            );
            assert_eq!(
                selected_index_of(&mut state),
                expected,
                "node {hit} sits at sibling position {} => index {expected}",
                (hit - 1) / 2
            );
            assert!(
                changes.is_empty(),
                "the handler must not live-restyle (navigation rebuilds the page)"
            );
        }
    }
    #[test]
    fn click_invokes_the_user_callback_with_the_clicked_index() {
        let mut log = RefAny::new(NavLog { seen: Vec::new() });
        let bc = Breadcrumb::create(labels(&["a", "b", "c", "d"]))
            .with_on_navigate(log.clone(), nav_cb(record_nav));
        let (styled, state) = flatten(bc);
        let (update, _) = run_click(Some(styled.clone()), 9, state.clone());
        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
        assert_eq!(log_indices(&mut log), vec![2]);
        // A second click updates the shared state again — the index is not sticky.
        let (_, _) = run_click(Some(styled), 1, state.clone());
        assert_eq!(log_indices(&mut log), vec![2, 0]);
        let mut state = state;
        assert_eq!(
            selected_index_of(&mut state),
            0,
            "the state must hold the *last* clicked index"
        );
    }
    #[test]
    fn click_propagates_every_update_variant_unchanged() {
        for (cb, expected) in [
            (nav_cb(nav_do_nothing), Update::DoNothing),
            (nav_cb(nav_refresh_all), Update::RefreshDomAllWindows),
        ] {
            let bc =
                Breadcrumb::create(labels(&["a", "b"])).with_on_navigate(RefAny::new(0u8), cb);
            let (styled, state) = flatten(bc);
            let (update, _) = run_click(Some(styled), 1, state);
            assert_eq!(update, expected);
        }
    }
    #[test]
    fn click_on_the_root_node_does_nothing() {
        // The root has no parent -> the handler must bail, not index into nothing.
        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
        let mut state2 = state.clone();
        let (update, changes) = run_click(Some(styled), 0, state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(selected_index_of(&mut state2), 0, "state must be untouched");
    }
    #[test]
    fn click_on_an_out_of_range_node_does_nothing() {
        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
        let (update, changes) = run_click(Some(styled), 9999, state);
        assert_eq!(
            update,
            Update::DoNothing,
            "a hit node that isn't in the tree must not panic"
        );
        assert!(changes.is_empty());
    }
    #[test]
    fn click_with_no_layout_result_does_nothing() {
        let bc = Breadcrumb::create(labels(&["a", "b"]));
        let dom = bc.dom();
        let state = crumb_state(&dom, 0);
        let (update, changes) = run_click(None, 1, state);
        assert_eq!(
            update,
            Update::DoNothing,
            "an empty LayoutWindow must be handled, not unwrapped"
        );
        assert!(changes.is_empty());
    }
    #[test]
    fn click_with_a_foreign_payload_does_nothing() {
        let (styled, _) = flatten(Breadcrumb::create(labels(&["a", "b"])));
        // Wrong type in the RefAny: downcast fails, handler must bail cleanly.
        let (update, changes) = run_click(Some(styled), 1, RefAny::new(0u32));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn click_with_the_state_already_borrowed_does_nothing() {
        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
        // A live mutable borrow on a sibling clone: `downcast_mut` inside the
        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
        let mut held = state.clone();
        let guard = held
            .downcast_mut::<BreadcrumbStateWrapper>()
            .expect("first borrow succeeds");
        let (update, changes) = run_click(Some(styled), 1, state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        drop(guard);
    }
    #[test]
    fn click_on_a_separator_or_the_current_page_maps_to_pos_over_two() {
        // Neither carries a callback, so this is unreachable in practice — but
        // the documented `index = position / 2` must still hold (and not panic)
        // if the handler is ever invoked on one.
        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b", "c", "d"])));
        for (hit, expected) in [(3usize, 0usize), (7, 1), (11, 2), (13, 3)] {
            let mut state = state.clone();
            let (update, _) = run_click(Some(styled.clone()), hit, state.clone());
            assert_eq!(update, Update::DoNothing);
            assert_eq!(
                selected_index_of(&mut state),
                expected,
                "node {hit} => position {} => index {expected}",
                (hit - 1) / 2
            );
        }
    }
    #[test]
    fn click_indices_stay_in_range_for_a_long_trail() {
        let n = 64;
        let (styled, state) = flatten(Breadcrumb::create(n_labels(n)));
        assert_eq!(styled.node_hierarchy.as_ref().len(), 4 * n - 1);
        // Last clickable crumb: index n-2, at child position 2*(n-2); flat id
        // of the child at position p is 1 + 2p (each earlier child is a <p>
        // plus its text leaf) => node 4n-7.
        let hit = 4 * n - 7;
        let mut state = state;
        let (_, _) = run_click(Some(styled), hit, state.clone());
        let idx = selected_index_of(&mut state);
        assert_eq!(idx, n - 2);
        assert!(
            idx < n,
            "the reported index must always address a real label"
        );
    }
}