1use 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
50const SEPARATOR_GLYPH: AzString = AzString::from_const_str("/");
52
53pub 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#[derive(Debug, Clone, PartialEq, Eq)]
78#[repr(C)]
79pub struct Breadcrumb {
80 pub breadcrumb_state: BreadcrumbStateWrapper,
81 pub labels: StringVec,
83 pub container_style: CssPropertyWithConditionsVec,
85}
86
87#[derive(Debug, Default, Clone, PartialEq, Eq)]
88#[repr(C)]
89pub struct BreadcrumbStateWrapper {
90 pub inner: BreadcrumbState,
92 pub on_navigate: OptionBreadcrumbOnNavigate,
94}
95
96#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
98#[repr(C)]
99pub struct BreadcrumbState {
100 pub selected_index: usize,
102}
103
104const LINK_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
107const CURRENT_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
109const SEPARATOR_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
111
112static 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
122static 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
134static 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
144static 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 #[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 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 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 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 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
274extern "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 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 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 fn labels(v: &[&str]) -> StringVec {
354 StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
355 }
356
357 fn n_labels(n: usize) -> StringVec {
359 StringVec::from_vec((0..n).map(|i| AzString::from(format!("c{i}"))).collect::<Vec<_>>())
360 }
361
362 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 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 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 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 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 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 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 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 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: ¤t_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 #[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 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 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) .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 #[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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 #[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 #[test]
1024 fn click_reports_the_index_of_each_crumb() {
1025 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 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 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 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 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 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 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}