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}