Skip to main content

azul_layout/widgets/
breadcrumb.rs

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
21use std::vec::Vec;
22
23use azul_core::{
24    callbacks::{CoreCallbackData, Update},
25    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26    refany::RefAny,
27};
28use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
29use 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
39use crate::callbacks::{Callback, CallbackInfo};
40
41static BREADCRUMB_CLASS: &[IdOrClass] =
42    &[Class(AzString::from_const_str("__azul-native-breadcrumb"))];
43static BREADCRUMB_ITEM_CLASS: &[IdOrClass] =
44    &[Class(AzString::from_const_str("__azul-native-breadcrumb-item"))];
45static BREADCRUMB_CURRENT_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-breadcrumb-current"))];
47static BREADCRUMB_SEPARATOR_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-breadcrumb-separator"))];
49
50/// Separator glyph rendered between crumbs.
51const SEPARATOR_GLYPH: AzString = AzString::from_const_str("/");
52
53/// Callback function type invoked when a (non-current) crumb is clicked.
54pub type BreadcrumbOnNavigateCallbackType =
55    extern "C" fn(RefAny, CallbackInfo, BreadcrumbState) -> Update;
56impl_widget_callback!(
57    BreadcrumbOnNavigate,
58    OptionBreadcrumbOnNavigate,
59    BreadcrumbOnNavigateCallback,
60    BreadcrumbOnNavigateCallbackType
61);
62
63azul_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)]
79pub 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)]
89pub 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)]
99pub 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).
106const LINK_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
107/// Current-crumb colour (#495057, muted dark grey).
108const CURRENT_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
109/// Separator colour (#6c757d, grey).
110const SEPARATOR_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
111
112/// Row container: a horizontal flex row that hugs its content.
113static 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.
125static 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.
135static 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.
145static 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
157impl Breadcrumb {
158    /// Creates a breadcrumb from the given labels (the last is the current page).
159    #[must_use] pub fn create(labels: StringVec) -> Self {
160        Self {
161            breadcrumb_state: BreadcrumbStateWrapper::default(),
162            labels,
163            container_style: CssPropertyWithConditionsVec::from_const_slice(
164                BREADCRUMB_CONTAINER_STYLE,
165            ),
166        }
167    }
168
169    #[inline]
170    #[must_use] pub fn swap_with_default(&mut self) -> Self {
171        let mut s = Self::create(StringVec::from_const_slice(&[]));
172        core::mem::swap(&mut s, self);
173        s
174    }
175
176    #[inline]
177    pub fn set_on_navigate<C: Into<BreadcrumbOnNavigateCallback>>(
178        &mut self,
179        data: RefAny,
180        on_navigate: C,
181    ) {
182        self.breadcrumb_state.on_navigate = Some(BreadcrumbOnNavigate {
183            callback: on_navigate.into(),
184            refany: data,
185        })
186        .into();
187    }
188
189    #[inline]
190    #[must_use] pub fn with_on_navigate<C: Into<BreadcrumbOnNavigateCallback>>(
191        mut self,
192        data: RefAny,
193        on_navigate: C,
194    ) -> Self {
195        self.set_on_navigate(data, on_navigate);
196        self
197    }
198
199    #[must_use] pub fn dom(self) -> Dom {
200        use azul_core::{
201            callbacks::CoreCallback,
202            dom::{EventFilter, HoverEventFilter},
203            refany::OptionRefAny,
204        };
205
206        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        let state = RefAny::new(self.breadcrumb_state);
211
212        let mut children: Vec<Dom> = Vec::with_capacity(count.saturating_mul(2));
213        for (i, label) in self.labels.as_ref().iter().enumerate() {
214            let is_last = i + 1 == count;
215
216            if is_last {
217                // The current page: muted + bold, non-clickable (no callback).
218                children.push(
219                    Dom::create_text(label.clone())
220                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
221                            BREADCRUMB_CURRENT_CLASS,
222                        ))
223                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
224                            BREADCRUMB_CURRENT_STYLE,
225                        )),
226                );
227            } else {
228                // A clickable crumb link.
229                children.push(
230                    Dom::create_text(label.clone())
231                        .with_ids_and_classes(IdOrClassVec::from_const_slice(BREADCRUMB_ITEM_CLASS))
232                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
233                            BREADCRUMB_ITEM_STYLE,
234                        ))
235                        .with_callbacks(
236                            vec![CoreCallbackData {
237                                event: EventFilter::Hover(HoverEventFilter::MouseUp),
238                                callback: CoreCallback {
239                                    cb: on_crumb_click as usize,
240                                    ctx: OptionRefAny::None,
241                                },
242                                refany: state.clone(),
243                            }]
244                            .into(),
245                        )
246                        .with_tab_index(TabIndex::Auto),
247                );
248                // Separator after every non-last crumb.
249                children.push(
250                    Dom::create_text(SEPARATOR_GLYPH)
251                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
252                            BREADCRUMB_SEPARATOR_CLASS,
253                        ))
254                        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
255                            BREADCRUMB_SEPARATOR_STYLE,
256                        )),
257                );
258            }
259        }
260
261        Dom::create_div()
262            .with_ids_and_classes(IdOrClassVec::from_const_slice(BREADCRUMB_CLASS))
263            .with_css_props(self.container_style)
264            .with_children(children.into())
265    }
266}
267
268impl Default for Breadcrumb {
269    fn default() -> Self {
270        Self::create(StringVec::from_const_slice(&[]))
271    }
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.
279extern "C" fn on_crumb_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
280    use azul_core::dom::DomNodeId;
281
282    let clicked = info.get_hit_node();
283    let Some(parent) = info.get_parent(clicked) else {
284        return Update::DoNothing;
285    };
286
287    // Collect the children in document order, then find the clicked crumb's slot.
288    let mut siblings: Vec<DomNodeId> = Vec::new();
289    let mut cur = info.get_first_child(parent);
290    while let Some(node) = cur {
291        siblings.push(node);
292        cur = info.get_next_sibling(node);
293    }
294
295    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    let index = pos / 2;
300
301    let Some(mut bc) = data.downcast_mut::<BreadcrumbStateWrapper>() else {
302        return Update::DoNothing;
303    };
304    bc.inner.selected_index = index;
305    let inner = bc.inner;
306    let bc = &mut *bc;
307    match bc.on_navigate.as_mut() {
308        Some(BreadcrumbOnNavigate { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
309        None => Update::DoNothing,
310    }
311}
312
313impl From<Breadcrumb> for Dom {
314    fn from(b: Breadcrumb) -> Self {
315        b.dom()
316    }
317}
318
319#[cfg(test)]
320mod 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 `NodeType::Text` node (`None` for any other node type).
363    fn text_of(node: &Dom) -> Option<&str> {
364        match node.root.get_node_type() {
365            NodeType::Text(s) => Some(s.as_ref().as_str()),
366            _ => None,
367        }
368    }
369
370    /// The true recursive descendant count of a `Dom` — what
371    /// `estimated_total_children` is documented to cache.
372    fn recursive_descendants(node: &Dom) -> usize {
373        node.children
374            .as_ref()
375            .iter()
376            .map(|c| 1 + recursive_descendants(c))
377            .sum()
378    }
379
380    /// The `color` (text colour) declared by a style slice.
381    fn text_color(style: &[CssPropertyWithConditions]) -> Option<ColorU> {
382        style.iter().find_map(|p| match &p.property {
383            CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
384            _ => None,
385        })
386    }
387
388    fn has_property(style: &[CssPropertyWithConditions], wanted: &CssProperty) -> bool {
389        style.iter().any(|p| p.property == *wanted)
390    }
391
392    fn has_cursor(style: &[CssPropertyWithConditions]) -> bool {
393        style
394            .iter()
395            .any(|p| matches!(&p.property, CssProperty::Cursor(_)))
396    }
397
398    /// A `RefAny` payload recording every index a user `on_navigate` sees.
399    struct NavLog {
400        seen: Vec<usize>,
401    }
402
403    extern "C" fn record_nav(mut data: RefAny, _: CallbackInfo, state: BreadcrumbState) -> Update {
404        if let Some(mut log) = data.downcast_mut::<NavLog>() {
405            log.seen.push(state.selected_index);
406        }
407        Update::RefreshDom
408    }
409
410    extern "C" fn nav_do_nothing(_: RefAny, _: CallbackInfo, _: BreadcrumbState) -> Update {
411        Update::DoNothing
412    }
413
414    extern "C" fn nav_refresh_all(_: RefAny, _: CallbackInfo, _: BreadcrumbState) -> Update {
415        Update::RefreshDomAllWindows
416    }
417
418    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
419    fn nav_cb(f: BreadcrumbOnNavigateCallbackType) -> BreadcrumbOnNavigateCallback {
420        f.into()
421    }
422
423    fn log_indices(data: &mut RefAny) -> Vec<usize> {
424        data.downcast_ref::<NavLog>()
425            .expect("payload must still be a NavLog")
426            .seen
427            .clone()
428    }
429
430    fn selected_index_of(data: &mut RefAny) -> usize {
431        data.downcast_ref::<BreadcrumbStateWrapper>()
432            .expect("payload must still be a BreadcrumbStateWrapper")
433            .inner
434            .selected_index
435    }
436
437    /// The `RefAny` carried by crumb `i`'s click callback (crumbs sit at even
438    /// child positions).
439    fn crumb_state(dom: &Dom, crumb: usize) -> RefAny {
440        let cbs = dom.children.as_ref()[crumb * 2].root.get_callbacks();
441        cbs.as_ref()
442            .first()
443            .expect("a non-last crumb must carry the click callback")
444            .refany
445            .clone()
446    }
447
448    /// A `DomLayoutResult` with an *empty* layout tree: `on_crumb_click` only
449    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
450    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
451        DomLayoutResult {
452            styled_dom,
453            layout_tree: LayoutTree {
454                nodes: Vec::new(),
455                warm: Vec::new(),
456                cold: Vec::new(),
457                root: 0,
458                dom_to_layout: BTreeMap::new(),
459                children_arena: Vec::new(),
460                children_offsets: Vec::new(),
461                subtree_needs_intrinsic: Vec::new(),
462            },
463            calculated_positions: Vec::new(),
464            viewport: LogicalRect::zero(),
465            display_list: DisplayList::default(),
466            scroll_ids: HashMap::new(),
467            scroll_id_to_node_id: HashMap::new(),
468        }
469    }
470
471    /// Flattens `bc.dom()` and hands back the shared state `RefAny` the crumb
472    /// callbacks carry. Requires >= 2 labels (so that crumb 0 is clickable).
473    fn flatten(bc: Breadcrumb) -> (StyledDom, RefAny) {
474        let dom = bc.dom();
475        let state = crumb_state(&dom, 0);
476        (StyledDom::create_from_dom(dom), state)
477    }
478
479    /// Invokes `on_crumb_click` against a `LayoutWindow` holding `styled` (or
480    /// nothing at all, when `styled` is `None`), with node `hit` as the hit node.
481    /// Returns the `Update` plus every recorded `CallbackChange`.
482    fn run_click(
483        styled: Option<StyledDom>,
484        hit: usize,
485        data: RefAny,
486    ) -> (Update, Vec<CallbackChange>) {
487        let mut layout_window =
488            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
489        if let Some(sd) = styled {
490            layout_window
491                .layout_results
492                .insert(DomId::ROOT_ID, layout_result(sd));
493        }
494
495        let renderer_resources = RendererResources::default();
496        let previous_window_state: Option<FullWindowState> = None;
497        let current_window_state = FullWindowState::default();
498        let gl_context = OptionGlContextPtr::None;
499        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
500            BTreeMap::new();
501        let window_handle = RawWindowHandle::Unsupported;
502        let system_callbacks = ExternalSystemCallbacks::rust_internal();
503
504        let ref_data = CallbackInfoRefData {
505            layout_window: &layout_window,
506            renderer_resources: &renderer_resources,
507            previous_window_state: &previous_window_state,
508            current_window_state: &current_window_state,
509            gl_context: &gl_context,
510            current_scroll_manager: &scroll_states,
511            current_window_handle: &window_handle,
512            system_callbacks: &system_callbacks,
513            system_style: Arc::new(SystemStyle::default()),
514            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
515            #[cfg(feature = "icu")]
516            icu_localizer: IcuLocalizerHandle::default(),
517            ctx: OptionRefAny::None,
518        };
519
520        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
521
522        let info = CallbackInfo::new(
523            &ref_data,
524            &changes,
525            DomNodeId {
526                dom: DomId::ROOT_ID,
527                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
528            },
529            OptionLogicalPosition::None,
530            OptionLogicalPosition::None,
531        );
532
533        let update = on_crumb_click(data, info);
534        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
535        (update, recorded)
536    }
537
538    // ------------------------------------------------------------------
539    // Breadcrumb::create
540    // ------------------------------------------------------------------
541
542    #[test]
543    fn create_preserves_labels_verbatim_and_defaults_the_state() {
544        for case in [
545            vec![],
546            vec!["only"],
547            vec!["Home", "Docs"],
548            vec!["Home", "Docs", "Widgets", "Breadcrumb"],
549        ] {
550            let bc = Breadcrumb::create(labels(&case));
551
552            let got: Vec<&str> = bc.labels.as_ref().iter().map(AzString::as_str).collect();
553            assert_eq!(got, case, "create must not reorder/drop/rewrite labels");
554
555            assert_eq!(
556                bc.breadcrumb_state.inner.selected_index, 0,
557                "a fresh breadcrumb starts at crumb 0"
558            );
559            assert!(
560                bc.breadcrumb_state.on_navigate.as_ref().is_none(),
561                "create must not install a callback"
562            );
563            assert_eq!(
564                bc.container_style.as_ref(),
565                BREADCRUMB_CONTAINER_STYLE,
566                "create must use the shared const container style"
567            );
568        }
569    }
570
571    #[test]
572    fn create_survives_pathological_labels() {
573        // empty string, whitespace-only, a label that *is* the separator glyph,
574        // emoji + ZWJ, RTL, combining marks, NUL, and a 100k-char label.
575        let huge = "x".repeat(100_000);
576        let case = vec![
577            "",
578            "   ",
579            "/",
580            "//",
581            "a\u{0}b",
582            "👨‍👩‍👧‍👦",
583            "مرحبا",
584            "e\u{0301}\u{0301}\u{0301}",
585            "\u{200b}\u{feff}",
586            huge.as_str(),
587        ];
588        let bc = Breadcrumb::create(labels(&case));
589
590        let got: Vec<&str> = bc.labels.as_ref().iter().map(AzString::as_str).collect();
591        assert_eq!(got, case, "labels must survive byte-for-byte");
592        assert_eq!(bc.labels.as_ref()[9].as_str().len(), 100_000);
593
594        // …and they must survive the trip through the DOM unchanged.
595        let dom = bc.dom();
596        let texts: Vec<&str> = dom
597            .children
598            .as_ref()
599            .iter()
600            .enumerate()
601            .filter(|(i, _)| i % 2 == 0) // crumbs sit at even positions
602            .filter_map(|(_, c)| text_of(c))
603            .collect();
604        assert_eq!(texts, case);
605    }
606
607    #[test]
608    fn create_with_many_labels_does_not_panic() {
609        let n = 10_000;
610        let bc = Breadcrumb::create(n_labels(n));
611        assert_eq!(bc.labels.as_ref().len(), n);
612        assert_eq!(bc.labels.as_ref()[n - 1].as_str(), "c9999");
613    }
614
615    #[test]
616    fn default_equals_create_with_no_labels() {
617        assert_eq!(
618            Breadcrumb::default(),
619            Breadcrumb::create(StringVec::from_const_slice(&[]))
620        );
621        assert!(Breadcrumb::default().labels.as_ref().is_empty());
622    }
623
624    // ------------------------------------------------------------------
625    // Breadcrumb::swap_with_default
626    // ------------------------------------------------------------------
627
628    #[test]
629    fn swap_with_default_returns_the_old_value_and_resets_self() {
630        let mut bc = Breadcrumb::create(labels(&["Home", "Docs", "Here"]));
631        let old = bc.swap_with_default();
632
633        let old_labels: Vec<&str> = old.labels.as_ref().iter().map(AzString::as_str).collect();
634        assert_eq!(old_labels, ["Home", "Docs", "Here"], "the old value moves out");
635        assert!(
636            bc.labels.as_ref().is_empty(),
637            "self must be left as a default (empty) breadcrumb"
638        );
639        assert_eq!(bc, Breadcrumb::default());
640    }
641
642    #[test]
643    fn swap_with_default_moves_the_callback_out_of_self() {
644        let mut bc = Breadcrumb::create(labels(&["a", "b"]))
645            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
646
647        let old = bc.swap_with_default();
648        assert!(
649            old.breadcrumb_state.on_navigate.as_ref().is_some(),
650            "the callback must travel with the returned value"
651        );
652        assert!(
653            bc.breadcrumb_state.on_navigate.as_ref().is_none(),
654            "self must not keep a dangling reference to the moved-out callback"
655        );
656    }
657
658    #[test]
659    fn swap_with_default_is_stable_when_repeated() {
660        let mut bc = Breadcrumb::create(labels(&["a"]));
661        let _ = bc.swap_with_default();
662        // Now `bc` is already a default — swapping again must keep returning
663        // defaults, not panic or corrupt state.
664        for _ in 0..100 {
665            let out = bc.swap_with_default();
666            assert_eq!(out, Breadcrumb::default());
667            assert_eq!(bc, Breadcrumb::default());
668        }
669    }
670
671    #[test]
672    fn swap_with_default_preserves_a_customised_state() {
673        let mut bc = Breadcrumb::create(labels(&["a", "b"]));
674        bc.breadcrumb_state.inner.selected_index = usize::MAX;
675
676        let old = bc.swap_with_default();
677        assert_eq!(
678            old.breadcrumb_state.inner.selected_index,
679            usize::MAX,
680            "an out-of-range index must move out untouched (no clamping/rewrite)"
681        );
682        assert_eq!(bc.breadcrumb_state.inner.selected_index, 0);
683    }
684
685    // ------------------------------------------------------------------
686    // Breadcrumb::set_on_navigate / with_on_navigate
687    // ------------------------------------------------------------------
688
689    #[test]
690    fn with_on_navigate_sets_the_callback_and_touches_nothing_else() {
691        let before = Breadcrumb::create(labels(&["Home", "Docs"]));
692        let after = Breadcrumb::create(labels(&["Home", "Docs"]))
693            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
694
695        assert_eq!(
696            after.labels, before.labels,
697            "installing a callback must not disturb the labels"
698        );
699        assert_eq!(
700            after.container_style, before.container_style,
701            "installing a callback must not disturb the container style"
702        );
703        assert_eq!(after.breadcrumb_state.inner.selected_index, 0);
704
705        let installed = after
706            .breadcrumb_state
707            .on_navigate
708            .as_ref()
709            .expect("with_on_navigate must install Some(..)");
710        assert_eq!(installed.callback.cb as usize, record_nav as usize);
711    }
712
713    #[test]
714    fn set_on_navigate_overwrites_the_previous_callback_and_data() {
715        let mut bc = Breadcrumb::create(labels(&["a", "b"]));
716        bc.set_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav));
717        bc.set_on_navigate(RefAny::new(42u32), nav_cb(nav_do_nothing));
718
719        let installed = bc
720            .breadcrumb_state
721            .on_navigate
722            .as_mut()
723            .expect("still Some after the overwrite");
724        assert_eq!(
725            installed.callback.cb as usize,
726            nav_do_nothing as usize,
727            "the last set_on_navigate must win"
728        );
729        assert_eq!(
730            installed.refany.downcast_ref::<u32>().map(|v| *v),
731            Some(42),
732            "the payload must be replaced along with the fn pointer"
733        );
734        assert!(
735            installed.refany.downcast_ref::<NavLog>().is_none(),
736            "the stale payload must be gone"
737        );
738    }
739
740    #[test]
741    fn set_on_navigate_accepts_a_generic_callback_without_corrupting_the_fn_pointer() {
742        // The FFI path (`From<Callback>`) transmutes the fn pointer. The value
743        // must round-trip bit-for-bit — a corrupted pointer would be an
744        // unconditional jump into garbage at click time. (Never invoked here.)
745        let raw = record_nav as usize;
746        let generic = Callback {
747            cb: unsafe { core::mem::transmute::<usize, crate::callbacks::CallbackType>(raw) },
748            ctx: OptionRefAny::None,
749        };
750        let converted: BreadcrumbOnNavigateCallback = generic.into();
751        assert_eq!(converted.cb as usize, raw);
752    }
753
754    #[test]
755    fn with_on_navigate_on_an_empty_breadcrumb_yields_a_dom_with_no_callbacks() {
756        // 0 labels => no crumbs => the installed callback is simply never wired up.
757        let dom = Breadcrumb::create(StringVec::from_const_slice(&[]))
758            .with_on_navigate(RefAny::new(NavLog { seen: Vec::new() }), nav_cb(record_nav))
759            .dom();
760
761        assert!(dom.children.as_ref().is_empty());
762        assert!(dom.root.get_callbacks().as_ref().is_empty());
763    }
764
765    // ------------------------------------------------------------------
766    // Breadcrumb::dom
767    // ------------------------------------------------------------------
768
769    #[test]
770    fn dom_of_no_labels_is_an_empty_container() {
771        let dom = Breadcrumb::create(StringVec::from_const_slice(&[])).dom();
772
773        assert!(
774            dom.children.as_ref().is_empty(),
775            "no labels => no crumbs and no separators"
776        );
777        assert_eq!(dom.estimated_total_children, 0);
778        assert!(dom.root.has_class("__azul-native-breadcrumb"));
779    }
780
781    #[test]
782    fn dom_of_a_single_label_has_no_separator_and_no_clickable_crumb() {
783        let dom = Breadcrumb::create(labels(&["Home"])).dom();
784        let children = dom.children.as_ref();
785
786        assert_eq!(children.len(), 1, "a lone label is the current page, nothing else");
787        assert_eq!(text_of(&children[0]), Some("Home"));
788        assert!(children[0].root.has_class("__azul-native-breadcrumb-current"));
789        assert!(
790            children[0].root.get_callbacks().as_ref().is_empty(),
791            "the current page must not be clickable"
792        );
793        assert_eq!(children[0].root.get_tab_index(), None);
794    }
795
796    #[test]
797    fn dom_alternates_crumb_separator_and_ends_on_the_current_page() {
798        let case = ["Home", "Docs", "Widgets", "Breadcrumb"];
799        let dom = Breadcrumb::create(labels(&case)).dom();
800        let children = dom.children.as_ref();
801
802        assert_eq!(children.len(), 2 * case.len() - 1, "n crumbs + (n-1) separators");
803
804        for (pos, child) in children.iter().enumerate() {
805            let is_last_child = pos + 1 == children.len();
806            if pos % 2 == 1 {
807                // separator
808                assert_eq!(text_of(child), Some("/"), "odd positions are separators");
809                assert!(child.root.has_class("__azul-native-breadcrumb-separator"));
810                assert!(
811                    child.root.get_callbacks().as_ref().is_empty(),
812                    "separators must not be clickable"
813                );
814                assert_eq!(child.root.get_tab_index(), None);
815            } else if is_last_child {
816                // current page
817                assert_eq!(text_of(child), Some(case[pos / 2]));
818                assert!(child.root.has_class("__azul-native-breadcrumb-current"));
819                assert!(
820                    child.root.get_callbacks().as_ref().is_empty(),
821                    "the current page must not be clickable"
822                );
823                assert_eq!(child.root.get_tab_index(), None);
824            } else {
825                // clickable crumb link
826                assert_eq!(text_of(child), Some(case[pos / 2]));
827                assert!(child.root.has_class("__azul-native-breadcrumb-item"));
828                let cbs = child.root.get_callbacks();
829                assert_eq!(cbs.as_ref().len(), 1, "one MouseUp handler per crumb");
830                assert_eq!(
831                    cbs.as_ref()[0].event,
832                    EventFilter::Hover(HoverEventFilter::MouseUp)
833                );
834                assert_eq!(cbs.as_ref()[0].callback.cb, on_crumb_click as usize);
835                assert_eq!(
836                    child.root.get_tab_index(),
837                    Some(TabIndex::Auto),
838                    "crumb links must be keyboard-reachable"
839                );
840            }
841        }
842    }
843
844    #[test]
845    fn dom_estimated_total_children_matches_the_real_descendant_count() {
846        // `estimated_total_children` is a cached count; if it under-counts,
847        // `convert_dom_into_compact_dom` under-allocates and panics.
848        for n in [0usize, 1, 2, 3, 5, 64, 257] {
849            let dom = Breadcrumb::create(n_labels(n)).dom();
850            let expected = if n == 0 { 0 } else { 2 * n - 1 };
851
852            assert_eq!(dom.children.as_ref().len(), expected, "child count for n={n}");
853            assert_eq!(
854                dom.estimated_total_children,
855                recursive_descendants(&dom),
856                "cached descendant count desynced for n={n}"
857            );
858            assert_eq!(dom.estimated_total_children, expected, "for n={n}");
859        }
860    }
861
862    #[test]
863    fn dom_of_many_labels_flattens_without_panicking() {
864        let n = 200;
865        let styled = StyledDom::create_from_dom(Breadcrumb::create(n_labels(n)).dom());
866        assert_eq!(
867            styled.node_hierarchy.as_ref().len(),
868            2 * n,
869            "root + (2n-1) children"
870        );
871    }
872
873    #[test]
874    fn dom_separator_is_told_apart_by_class_not_by_text() {
875        // A label that is literally "/" must still be a crumb, not a separator.
876        let dom = Breadcrumb::create(labels(&["/", "b"])).dom();
877        let children = dom.children.as_ref();
878
879        assert_eq!(text_of(&children[0]), Some("/"));
880        assert!(children[0].root.has_class("__azul-native-breadcrumb-item"));
881        assert!(!children[0].root.has_class("__azul-native-breadcrumb-separator"));
882        assert!(!children[0].root.get_callbacks().as_ref().is_empty());
883
884        assert_eq!(text_of(&children[1]), Some("/"));
885        assert!(children[1].root.has_class("__azul-native-breadcrumb-separator"));
886        assert!(children[1].root.get_callbacks().as_ref().is_empty());
887    }
888
889    #[test]
890    fn dom_shares_one_state_refany_across_every_crumb() {
891        let dom = Breadcrumb::create(labels(&["a", "b", "c", "d"])).dom();
892
893        // Write through crumb 0's handle…
894        let mut first = crumb_state(&dom, 0);
895        {
896            let mut w = first
897                .downcast_mut::<BreadcrumbStateWrapper>()
898                .expect("crumb state must be a BreadcrumbStateWrapper");
899            w.inner.selected_index = 7;
900        }
901        // …and read it back through crumb 2's handle.
902        let mut third = crumb_state(&dom, 2);
903        assert_eq!(
904            selected_index_of(&mut third),
905            7,
906            "every crumb must observe the same shared state"
907        );
908    }
909
910    #[test]
911    fn dom_gives_separate_breadcrumbs_separate_state() {
912        let a = Breadcrumb::create(labels(&["a", "b"])).dom();
913        let b = Breadcrumb::create(labels(&["a", "b"])).dom();
914
915        let mut a0 = crumb_state(&a, 0);
916        {
917            let mut w = a0.downcast_mut::<BreadcrumbStateWrapper>().unwrap();
918            w.inner.selected_index = 3;
919        }
920
921        let mut b0 = crumb_state(&b, 0);
922        assert_eq!(
923            selected_index_of(&mut b0),
924            0,
925            "two breadcrumbs must not alias one another's state"
926        );
927    }
928
929    #[test]
930    fn dom_carries_the_container_style_through() {
931        let bc = Breadcrumb::create(labels(&["a", "b"]));
932        assert_eq!(bc.container_style.as_ref(), BREADCRUMB_CONTAINER_STYLE);
933
934        let dom = bc.dom();
935        assert!(dom.root.has_class("__azul-native-breadcrumb"));
936        assert!(
937            dom.root.is_node_type(NodeType::Div),
938            "the row container must be a div"
939        );
940    }
941
942    // ------------------------------------------------------------------
943    // Style constants (invariants the widget's look depends on)
944    // ------------------------------------------------------------------
945
946    #[test]
947    fn crumb_styles_are_opaque_and_visually_distinct() {
948        for (name, c) in [
949            ("link", LINK_COLOR),
950            ("current", CURRENT_COLOR),
951            ("separator", SEPARATOR_COLOR),
952        ] {
953            assert_eq!(c.a, 255, "{name} colour must be fully opaque");
954        }
955        assert_ne!(
956            LINK_COLOR, CURRENT_COLOR,
957            "the current page must not look like a link"
958        );
959
960        assert_eq!(text_color(BREADCRUMB_ITEM_STYLE), Some(LINK_COLOR));
961        assert_eq!(text_color(BREADCRUMB_CURRENT_STYLE), Some(CURRENT_COLOR));
962        assert_eq!(text_color(BREADCRUMB_SEPARATOR_STYLE), Some(SEPARATOR_COLOR));
963    }
964
965    #[test]
966    fn only_the_clickable_crumb_style_declares_a_pointer_cursor() {
967        assert!(has_property(
968            BREADCRUMB_ITEM_STYLE,
969            &CssProperty::const_cursor(StyleCursor::Pointer)
970        ));
971        assert!(
972            !has_cursor(BREADCRUMB_CURRENT_STYLE),
973            "the current page is not clickable, so it must not advertise a pointer"
974        );
975        assert!(
976            !has_cursor(BREADCRUMB_SEPARATOR_STYLE),
977            "separators are not clickable"
978        );
979    }
980
981    #[test]
982    fn separator_style_has_symmetric_horizontal_margins() {
983        assert!(has_property(
984            BREADCRUMB_SEPARATOR_STYLE,
985            &CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))
986        ));
987        assert!(has_property(
988            BREADCRUMB_SEPARATOR_STYLE,
989            &CssProperty::const_margin_right(LayoutMarginRight::const_px(8))
990        ));
991    }
992
993    #[test]
994    fn every_crumb_style_disables_text_selection_and_flex_growth() {
995        for (name, style) in [
996            ("item", BREADCRUMB_ITEM_STYLE),
997            ("current", BREADCRUMB_CURRENT_STYLE),
998            ("separator", BREADCRUMB_SEPARATOR_STYLE),
999        ] {
1000            assert!(
1001                has_property(style, &CssProperty::user_select(StyleUserSelect::None)),
1002                "{name}: dragging across a breadcrumb must not select its text"
1003            );
1004            assert!(
1005                has_property(
1006                    style,
1007                    &CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
1008                ),
1009                "{name}: crumbs must hug their content"
1010            );
1011        }
1012        assert!(has_property(
1013            BREADCRUMB_CURRENT_STYLE,
1014            &CssProperty::font_weight(StyleFontWeight::Bold)
1015        ));
1016        assert_eq!(SEPARATOR_GLYPH.as_str(), "/");
1017    }
1018
1019    // ------------------------------------------------------------------
1020    // on_crumb_click
1021    // ------------------------------------------------------------------
1022
1023    #[test]
1024    fn click_reports_the_index_of_each_crumb() {
1025        // children: crumb0(1) sep(2) crumb1(3) sep(4) crumb2(5) sep(6) current(7)
1026        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b", "c", "d"])));
1027        assert_eq!(
1028            styled.node_hierarchy.as_ref().len(),
1029            8,
1030            "fixture must flatten to root + 7 children"
1031        );
1032
1033        for (hit, expected) in [(1usize, 0usize), (3, 1), (5, 2)] {
1034            let mut state = state.clone();
1035            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
1036
1037            assert_eq!(
1038                update,
1039                Update::DoNothing,
1040                "with no on_navigate installed the handler reports nothing to redraw"
1041            );
1042            assert_eq!(
1043                selected_index_of(&mut state),
1044                expected,
1045                "node {hit} sits at sibling position {} => index {expected}",
1046                hit - 1
1047            );
1048            assert!(
1049                changes.is_empty(),
1050                "the handler must not live-restyle (navigation rebuilds the page)"
1051            );
1052        }
1053    }
1054
1055    #[test]
1056    fn click_invokes_the_user_callback_with_the_clicked_index() {
1057        let mut log = RefAny::new(NavLog { seen: Vec::new() });
1058        let bc = Breadcrumb::create(labels(&["a", "b", "c", "d"]))
1059            .with_on_navigate(log.clone(), nav_cb(record_nav));
1060        let (styled, state) = flatten(bc);
1061
1062        let (update, _) = run_click(Some(styled.clone()), 5, state.clone());
1063        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
1064        assert_eq!(log_indices(&mut log), vec![2]);
1065
1066        // A second click updates the shared state again — the index is not sticky.
1067        let (_, _) = run_click(Some(styled), 1, state.clone());
1068        assert_eq!(log_indices(&mut log), vec![2, 0]);
1069
1070        let mut state = state;
1071        assert_eq!(
1072            selected_index_of(&mut state),
1073            0,
1074            "the state must hold the *last* clicked index"
1075        );
1076    }
1077
1078    #[test]
1079    fn click_propagates_every_update_variant_unchanged() {
1080        for (cb, expected) in [
1081            (nav_cb(nav_do_nothing), Update::DoNothing),
1082            (nav_cb(nav_refresh_all), Update::RefreshDomAllWindows),
1083        ] {
1084            let bc =
1085                Breadcrumb::create(labels(&["a", "b"])).with_on_navigate(RefAny::new(0u8), cb);
1086            let (styled, state) = flatten(bc);
1087            let (update, _) = run_click(Some(styled), 1, state);
1088            assert_eq!(update, expected);
1089        }
1090    }
1091
1092    #[test]
1093    fn click_on_the_root_node_does_nothing() {
1094        // The root has no parent -> the handler must bail, not index into nothing.
1095        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
1096        let mut state2 = state.clone();
1097
1098        let (update, changes) = run_click(Some(styled), 0, state);
1099        assert_eq!(update, Update::DoNothing);
1100        assert!(changes.is_empty());
1101        assert_eq!(selected_index_of(&mut state2), 0, "state must be untouched");
1102    }
1103
1104    #[test]
1105    fn click_on_an_out_of_range_node_does_nothing() {
1106        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
1107        let (update, changes) = run_click(Some(styled), 9999, state);
1108        assert_eq!(
1109            update,
1110            Update::DoNothing,
1111            "a hit node that isn't in the tree must not panic"
1112        );
1113        assert!(changes.is_empty());
1114    }
1115
1116    #[test]
1117    fn click_with_no_layout_result_does_nothing() {
1118        let bc = Breadcrumb::create(labels(&["a", "b"]));
1119        let dom = bc.dom();
1120        let state = crumb_state(&dom, 0);
1121
1122        let (update, changes) = run_click(None, 1, state);
1123        assert_eq!(
1124            update,
1125            Update::DoNothing,
1126            "an empty LayoutWindow must be handled, not unwrapped"
1127        );
1128        assert!(changes.is_empty());
1129    }
1130
1131    #[test]
1132    fn click_with_a_foreign_payload_does_nothing() {
1133        let (styled, _) = flatten(Breadcrumb::create(labels(&["a", "b"])));
1134        // Wrong type in the RefAny: downcast fails, handler must bail cleanly.
1135        let (update, changes) = run_click(Some(styled), 1, RefAny::new(0u32));
1136        assert_eq!(update, Update::DoNothing);
1137        assert!(changes.is_empty());
1138    }
1139
1140    #[test]
1141    fn click_with_the_state_already_borrowed_does_nothing() {
1142        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b"])));
1143
1144        // A live mutable borrow on a sibling clone: `downcast_mut` inside the
1145        // handler must fail (returning DoNothing) instead of aliasing `&mut`.
1146        let mut held = state.clone();
1147        let guard = held
1148            .downcast_mut::<BreadcrumbStateWrapper>()
1149            .expect("first borrow succeeds");
1150
1151        let (update, changes) = run_click(Some(styled), 1, state);
1152        assert_eq!(update, Update::DoNothing);
1153        assert!(changes.is_empty());
1154        drop(guard);
1155    }
1156
1157    #[test]
1158    fn click_on_a_separator_or_the_current_page_maps_to_pos_over_two() {
1159        // Neither carries a callback, so this is unreachable in practice — but
1160        // the documented `index = position / 2` must still hold (and not panic)
1161        // if the handler is ever invoked on one.
1162        let (styled, state) = flatten(Breadcrumb::create(labels(&["a", "b", "c", "d"])));
1163
1164        for (hit, expected) in [(2usize, 0usize), (4, 1), (6, 2), (7, 3)] {
1165            let mut state = state.clone();
1166            let (update, _) = run_click(Some(styled.clone()), hit, state.clone());
1167            assert_eq!(update, Update::DoNothing);
1168            assert_eq!(
1169                selected_index_of(&mut state),
1170                expected,
1171                "node {hit} => position {} => index {expected}",
1172                hit - 1
1173            );
1174        }
1175    }
1176
1177    #[test]
1178    fn click_indices_stay_in_range_for_a_long_trail() {
1179        let n = 64;
1180        let (styled, state) = flatten(Breadcrumb::create(n_labels(n)));
1181        assert_eq!(styled.node_hierarchy.as_ref().len(), 2 * n);
1182
1183        // Last clickable crumb: index n-2, at child position 2*(n-2) => node 2n-3.
1184        let hit = 2 * n - 3;
1185        let mut state = state;
1186        let (_, _) = run_click(Some(styled), hit, state.clone());
1187
1188        let idx = selected_index_of(&mut state);
1189        assert_eq!(idx, n - 2);
1190        assert!(
1191            idx < n,
1192            "the reported index must always address a real label"
1193        );
1194    }
1195}