Skip to main content

teksilo_widgets/
breadcrumb.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Breadcrumb — a navigational trail with automatic overflow into a `…` menu.
5//!
6//! `Breadcrumb` renders a horizontal row of labelled segments separated by
7//! chevron glyphs, representing a hierarchical path (file system, settings
8//! hierarchy, wizard steps, etc.). When the trail is too wide to fit its
9//! container, middle segments are automatically collapsed into a `…` popover
10//! menu — the root and the current (last) segment always stay visible,
11//! matching Windows Explorer, macOS path bar, and web breadcrumb conventions.
12//!
13//! ## Building a trail
14//!
15//! ```rust
16//! # use teksilo_widgets::{Breadcrumb, BreadcrumbItem};
17//! # use teksilo_core::Intent;
18//! # use teksilo_i18n::lit;
19//! let _bc = Breadcrumb::new()
20//!     .item(BreadcrumbItem::new(lit!("Home"))
21//!         .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.nav.home"))))
22//!     .item(BreadcrumbItem::new(lit!("Projects"))
23//!         .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.nav.projects"))))
24//!     .item(BreadcrumbItem::current(lit!("Teksilo")));
25//! ```
26//!
27//! ## Accessibility
28//!
29//! The container uses `Role::Navigation`; each segment uses `Role::Link`.
30//! The current crumb sets `aria-current="page"`. The decorative separator
31//! chevrons are hidden from the AT tree. The `…` overflow button declares
32//! `HasPopup::Menu`.
33
34use std::cell::RefCell;
35use std::rc::Rc;
36
37use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::accesskit::HasPopup;
40use teksilo_core::binding::BindingLevel;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::environment::LayoutDirection;
43use teksilo_core::event::{EventResponse, Key, WidgetEvent};
44use teksilo_core::overlay::OverlayPlacement;
45use teksilo_core::signal::Signal;
46use teksilo_core::widget::{
47    CursorIcon, EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
48};
49use teksilo_core::widget_builder::HandlerSet;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::{Color, CornerRadius};
52
53use crate::button::{Button, ButtonVariant};
54use crate::menu_item::MenuItem;
55use crate::menu_list::MenuList;
56use crate::popover_widget::PopoverButton;
57use crate::primitives::{HStack, IconWidget, Spacer};
58use teksilo_i18n::LocalizedString;
59
60const FALLBACK_CHAR_WIDTH: f32 = 8.0;
61const FALLBACK_LINE_HEIGHT: f32 = 16.0;
62
63/// Shared activation closure. `Rc` (not `Box`) so a crumb's action can be
64/// fired from BOTH its inline segment AND its row in the overflow menu.
65type CommandFactory = Rc<dyn Fn(&mut EventContext)>;
66
67struct BreadcrumbEntry {
68    label: LocalizedString,
69    action: Option<CommandFactory>,
70    current: bool,
71    tooltip_text: Option<LocalizedString>,
72    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
73    composite_tooltip_content: Option<Box<dyn Widget>>,
74}
75
76impl std::fmt::Debug for BreadcrumbEntry {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("BreadcrumbEntry")
79            .field("label", &self.label)
80            .field("current", &self.current)
81            .finish()
82    }
83}
84
85/// Minimum height of a single breadcrumb segment in logical pixels.
86pub const BREADCRUMB_ITEM_HEIGHT: f32 = 20.0;
87/// Horizontal inner padding of each segment pill in logical pixels.
88pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL: f32 = 6.0;
89/// Gap reserved for the chevron separator between adjacent segments.
90pub const BREADCRUMB_SEPARATOR_GAP: f32 = 4.0;
91/// Corner radius of the interactive segment hover/focus rectangle.
92pub const BREADCRUMB_CORNER_RADIUS: f32 = 4.0;
93
94/// A single breadcrumb segment definition.
95pub struct BreadcrumbItem {
96    label: LocalizedString,
97    action: Option<CommandFactory>,
98    current: bool,
99    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
100    /// with the rich / composite slots — every setter clears the other two so
101    /// the last call wins.
102    tooltip_text: Option<LocalizedString>,
103    /// Optional rich tooltip source (registry key or inline content).
104    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
105    /// Optional composite tooltip body (arbitrary widget tree).
106    composite_tooltip_content: Option<Box<dyn Widget>>,
107}
108
109impl std::fmt::Debug for BreadcrumbItem {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("BreadcrumbItem")
112            .field("label", &self.label)
113            .field("current", &self.current)
114            .finish()
115    }
116}
117
118impl BreadcrumbItem {
119    /// Construct a non-current (navigable) breadcrumb segment.
120    pub fn new(label: impl Into<LocalizedString>) -> Self {
121        let ls: LocalizedString = label.into();
122        Self {
123            label: ls,
124            action: None,
125            current: false,
126            tooltip_text: None,
127            rich_tooltip_source: None,
128            composite_tooltip_content: None,
129        }
130    }
131
132    /// Construct the current (last) breadcrumb segment, announced
133    /// with `aria-current="page"`. Current segments are never
134    /// collapsed into the overflow `…` menu.
135    pub fn current(label: impl Into<LocalizedString>) -> Self {
136        let ls: LocalizedString = label.into();
137        Self {
138            label: ls,
139            action: None,
140            current: true,
141            tooltip_text: None,
142            rich_tooltip_source: None,
143            composite_tooltip_content: None,
144        }
145    }
146
147    /// Closure invoked on activation.
148    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
149        self.action = Some(Rc::new(f));
150        self
151    }
152
153    /// Attach a plain single-line tooltip to this breadcrumb segment, shown
154    /// after a hover delay. Clears any previously set rich or composite tooltip.
155    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
156        self.tooltip_text = Some(text.into());
157        self.rich_tooltip_source = None;
158        self.composite_tooltip_content = None;
159        self
160    }
161
162    /// Attach a rich tooltip to this breadcrumb segment, looked up by registry
163    /// key. Clears any previously set plain or composite tooltip.
164    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
165        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
166        self.tooltip_text = None;
167        self.composite_tooltip_content = None;
168        self
169    }
170
171    /// Attach a rich tooltip to this breadcrumb segment from inline content.
172    /// Clears any previously set plain or composite tooltip.
173    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
174        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
175        self.tooltip_text = None;
176        self.composite_tooltip_content = None;
177        self
178    }
179
180    /// Attach a composite tooltip (arbitrary widget tree) to this breadcrumb
181    /// segment. Clears any previously set plain or rich tooltip.
182    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
183        self.composite_tooltip_content = Some(Box::new(content));
184        self.tooltip_text = None;
185        self.rich_tooltip_source = None;
186        self
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum SegmentInteraction {
192    Idle,
193    Hovered,
194    Focused,
195}
196
197struct BreadcrumbSegment {
198    label: LocalizedString,
199    action: Option<CommandFactory>,
200    current: bool,
201    interaction: Signal<SegmentInteraction>,
202    tooltip_text: Option<LocalizedString>,
203    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
204    composite_tooltip_content: Option<Box<dyn Widget>>,
205}
206
207impl std::fmt::Debug for BreadcrumbSegment {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("BreadcrumbSegment")
210            .field("label", &self.label)
211            .field("current", &self.current)
212            .field("interaction", &self.interaction.get())
213            .finish()
214    }
215}
216
217impl BreadcrumbSegment {
218    fn new(label: LocalizedString, action: Option<CommandFactory>, current: bool) -> Self {
219        Self {
220            label,
221            action,
222            current,
223            interaction: Signal::new(SegmentInteraction::Idle),
224            tooltip_text: None,
225            rich_tooltip_source: None,
226            composite_tooltip_content: None,
227        }
228    }
229
230    /// Move a [`BreadcrumbItem`]'s tooltip slots onto the built segment.
231    /// The three are mutually exclusive by construction (every `BreadcrumbItem`
232    /// setter clears the other two), so at most one is ever `Some`.
233    fn with_tooltip(
234        mut self,
235        tooltip_text: Option<LocalizedString>,
236        rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
237        composite_tooltip_content: Option<Box<dyn Widget>>,
238    ) -> Self {
239        self.tooltip_text = tooltip_text;
240        self.rich_tooltip_source = rich_tooltip_source;
241        self.composite_tooltip_content = composite_tooltip_content;
242        self
243    }
244
245    fn is_interactive(&self) -> bool {
246        !self.current && self.action.is_some()
247    }
248
249    fn estimate_width(&self, ctx: &LayoutContext) -> f32 {
250        let pad_h = BREADCRUMB_ITEM_PADDING_HORIZONTAL;
251        let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
252        let resolved = self.label.resolve_now();
253        let text_width = if let Some(backend) = ctx.text_backend {
254            backend
255                .borrow_mut()
256                .layout_single_line(&resolved, &ctx.theme.typography.small, None)
257                .width
258        } else {
259            resolved.len() as f32 * FALLBACK_CHAR_WIDTH
260        };
261        text_width + pad_h * 2.0 + envelope * 2.0
262    }
263}
264
265impl Widget for BreadcrumbSegment {
266    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
267        let self_id = ctx.self_id();
268        let interaction = ctx.signal(SegmentInteraction::Idle);
269        let registry = ctx.binding_registry();
270        interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
271        // Locale changes can alter the resolved label (and its width), so
272        // re-measure + repaint this custom-painted segment on locale switch.
273        ctx.locale_signal()
274            .bind_to(self_id, registry, BindingLevel::Relayout);
275        self.interaction = interaction.clone();
276
277        let interactive = self.is_interactive();
278        let action = self.action.take();
279        let action_for_tap = action.clone();
280        let action_for_key = action.clone();
281        let action_for_access = action;
282
283        let handler_set = HandlerSet::new()
284            .on_tap({
285                let interaction = interaction.clone();
286                move |_pos, ctx: &mut EventContext| {
287                    if !interactive {
288                        return;
289                    }
290                    if let Some(ref action) = action_for_tap {
291                        action(ctx);
292                    }
293                    interaction.set(SegmentInteraction::Hovered);
294                }
295            })
296            .on_hover({
297                let interaction = interaction.clone();
298                move |entered: bool, _ctx: &mut EventContext| {
299                    if !interactive {
300                        interaction.set(SegmentInteraction::Idle);
301                        return;
302                    }
303                    if interaction.get() == SegmentInteraction::Focused {
304                        return;
305                    }
306                    interaction.set(if entered {
307                        SegmentInteraction::Hovered
308                    } else {
309                        SegmentInteraction::Idle
310                    });
311                }
312            })
313            .on_focus({
314                let interaction = interaction.clone();
315                move |gained: bool, _ctx: &mut EventContext| {
316                    if !interactive {
317                        interaction.set(SegmentInteraction::Idle);
318                        return;
319                    }
320                    interaction.set(if gained {
321                        SegmentInteraction::Focused
322                    } else {
323                        SegmentInteraction::Idle
324                    });
325                }
326            })
327            .on_key({
328                let interaction = interaction.clone();
329                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
330                    if !interactive {
331                        return EventResponse::Ignored;
332                    }
333                    match event {
334                        WidgetEvent::KeyDown {
335                            key: Key::Enter | Key::Space,
336                            ..
337                        } => {
338                            if let Some(ref action) = action_for_key {
339                                action(ctx);
340                            }
341                            interaction.set(SegmentInteraction::Focused);
342                            EventResponse::Handled
343                        }
344                        _ => EventResponse::Ignored,
345                    }
346                }
347            })
348            .on_access_action(move |action, ctx: &mut EventContext| {
349                if interactive && action == teksilo_core::accesskit::Action::Click {
350                    if let Some(ref action) = action_for_access {
351                        action(ctx);
352                    }
353                    EventResponse::Handled
354                } else {
355                    EventResponse::Ignored
356                }
357            })
358            .focusable(interactive)
359            .cursor(if interactive {
360                CursorIcon::Pointer
361            } else {
362                CursorIcon::Default
363            });
364
365        ctx.apply_self_handlers(handler_set);
366
367        // Attach the tooltip carried over from `BreadcrumbItem`. The segment
368        // paints itself and returns no children, so it is its own anchor.
369        // `Side` placement: crumbs sit in a horizontal strip, but the trail can
370        // wrap, and a `Below` tip would cover the row beneath.
371        if let Some(content) = self.composite_tooltip_content.take() {
372            let delay = ctx.theme().motion.tooltip_delay_heavy;
373            crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
374        } else if let Some(source) = self.rich_tooltip_source.take() {
375            let delay = ctx.theme().motion.tooltip_delay;
376            crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
377        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
378            let delay = ctx.theme().motion.tooltip_delay;
379            crate::tooltip::attach_plain_tooltip(ctx, self_id, tooltip_text, delay);
380        }
381
382        Vec::new()
383    }
384
385    fn layout_response(
386        &self,
387        proposal: SizeProposal,
388        ctx: &LayoutContext,
389    ) -> teksilo_core::widget::LayoutResponse {
390        let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
391        let width = proposal.width.unwrap_or_else(|| self.estimate_width(ctx));
392        let text_height = if let Some(backend) = ctx.text_backend {
393            backend
394                .borrow_mut()
395                .layout_single_line(&self.label.resolve_now(), &ctx.theme.typography.small, None)
396                .height
397        } else {
398            FALLBACK_LINE_HEIGHT
399        };
400        let visual_h = text_height.max(BREADCRUMB_ITEM_HEIGHT);
401        Size::new(width, visual_h + envelope * 2.0).into()
402    }
403
404    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
405        let colors = &ctx.theme.colors;
406        let shape = &ctx.theme.shape;
407        let envelope = shape.focus_ring_offset + shape.focus_ring_width;
408        let interaction = self.interaction.get();
409        let interactive = self.is_interactive();
410
411        // Visual bounds — inset by the focus-ring envelope.
412        let visual = Rect::new(
413            bounds.x + envelope,
414            bounds.y + envelope,
415            (bounds.width - envelope * 2.0).max(0.0),
416            (bounds.height - envelope * 2.0).max(0.0),
417        );
418
419        if interactive {
420            let background = if interaction == SegmentInteraction::Hovered {
421                colors.accent.with_alpha(0.08)
422            } else if interaction == SegmentInteraction::Focused {
423                colors.accent.with_alpha(0.12)
424            } else {
425                Color::TRANSPARENT
426            };
427            if background.a() > 0.0 {
428                canvas.fill_rounded_rect(
429                    visual,
430                    CornerRadius::uniform(BREADCRUMB_CORNER_RADIUS),
431                    background,
432                );
433            }
434            // Focus ring — drawn outside the visual, inside the reserved envelope.
435            if interaction == SegmentInteraction::Focused {
436                let half_stroke = shape.focus_ring_width * 0.5;
437                let ring_rect = Rect::new(
438                    bounds.x + half_stroke,
439                    bounds.y + half_stroke,
440                    (bounds.width - half_stroke * 2.0).max(0.0),
441                    (bounds.height - half_stroke * 2.0).max(0.0),
442                );
443                let ring_radius = BREADCRUMB_CORNER_RADIUS + shape.focus_ring_offset + half_stroke;
444                canvas.stroke_rounded_rect(
445                    ring_rect,
446                    CornerRadius::uniform(ring_radius),
447                    colors.focus_ring,
448                    shape.focus_ring_width,
449                );
450            }
451        }
452
453        let text_color = if self.current {
454            colors.text_primary
455        } else if interactive && interaction == SegmentInteraction::Hovered {
456            colors.accent_hover
457        } else if interactive {
458            colors.accent
459        } else {
460            colors.text_secondary
461        };
462
463        let pad_h = BREADCRUMB_ITEM_PADDING_HORIZONTAL;
464        let text_bounds = Rect::new(
465            visual.x + pad_h,
466            visual.y,
467            (visual.width - pad_h * 2.0).max(0.0),
468            visual.height,
469        );
470        canvas.draw_text(
471            &self.label.resolve_now(),
472            text_bounds,
473            &ctx.theme.typography.small,
474            text_color,
475        );
476    }
477
478    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
479        // Every crumb keeps Role::Link — ARIA convention is that the
480        // current page is still announced as a link, just tagged with
481        // `aria-current="page"` so screen readers say "current page,
482        // <label>". Replaces the earlier Label-role + synthesized
483        // i18n `set_value` workaround which didn't map to a standard
484        // ARIA pattern.
485        builder.set_role(teksilo_core::accesskit::Role::Link);
486        builder.set_name(self.label.resolve_now());
487        if self.current {
488            builder.set_aria_current(teksilo_core::accesskit::AriaCurrent::Page);
489        } else if self.is_interactive() {
490            builder.add_action(teksilo_core::accesskit::Action::Click);
491            builder.add_action(teksilo_core::accesskit::Action::Focus);
492        }
493    }
494}
495
496#[derive(Debug)]
497struct BreadcrumbSeparator;
498
499impl Widget for BreadcrumbSeparator {
500    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
501        // Repaint on locale change so the chevron can flip with the layout
502        // direction (it points toward the next crumb: right in LTR, left in
503        // RTL).
504        let self_id = ctx.self_id();
505        let registry = ctx.binding_registry();
506        ctx.locale_signal()
507            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
508        Vec::new()
509    }
510
511    fn layout_response(
512        &self,
513        _proposal: SizeProposal,
514        ctx: &LayoutContext,
515    ) -> teksilo_core::widget::LayoutResponse {
516        let _ = ctx;
517        Size::new(BREADCRUMB_SEPARATOR_GAP * 3.0, BREADCRUMB_ITEM_HEIGHT).into()
518    }
519
520    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
521        let size = 10.0;
522        let icon_bounds = Rect::new(
523            bounds.x + (bounds.width - size) / 2.0,
524            bounds.y + (bounds.height - size) / 2.0,
525            size,
526            size,
527        );
528        // Role-based: IconWidget resolves against the current theme at paint,
529        // so this stays reactive across theme switches. The chevron mirrors
530        // under RTL — it always points toward the *next* crumb.
531        let icon = if ctx.layout_direction == LayoutDirection::RightToLeft {
532            IconWidget::chevron_left(size)
533        } else {
534            IconWidget::chevron_right(size)
535        }
536        .color(teksilo_tokens::TextRole::Secondary);
537        icon.paint(icon_bounds, canvas, ctx);
538    }
539
540    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
541        // Decorative chevron between crumbs. Screen readers would
542        // otherwise enumerate a generic container between every pair
543        // of links; `set_hidden()` keeps the node in the layout tree
544        // but removes it from the platform a11y tree.
545        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
546        builder.set_hidden();
547    }
548}
549
550enum BreadcrumbSlot {
551    Entry(Box<BreadcrumbEntry>),
552    Id(WidgetId),
553}
554
555/// Menu-form of a collapsible crumb — the row shown in the overflow `…` menu.
556struct CrumbMenuForm {
557    slot: usize,
558    label: LocalizedString,
559    action: Option<CommandFactory>,
560}
561
562/// A breadcrumb navigation row with **automatic overflow**: when the trail is
563/// too wide, the middle crumbs collapse into a trailing-of-root `…` menu while
564/// the root and the current (last) crumb stay visible — the standard breadcrumb
565/// collapse (Windows Explorer / web breadcrumbs / macOS path bar).
566pub struct Breadcrumb {
567    slots: Vec<BreadcrumbSlot>,
568    trailing_slot: Option<PendingChild>,
569    label: Option<LocalizedString>,
570
571    // Reactive state.
572    /// Per-slot collapsed flag (`true` = hidden in the `…` menu). Only
573    /// collapsible slots are ever set; index-aligned with the slots.
574    collapsed: Signal<Vec<bool>>,
575    /// Whether any crumb is currently collapsed (drives the chevron).
576    is_overflowing: Signal<bool>,
577
578    // Build state.
579    /// Per-slot "unit" id (the slot's segment, plus its leading separator for
580    /// slots after the first) — measured to compute overflow.
581    unit_ids: Vec<WidgetId>,
582    /// Per-slot: can this crumb collapse? (Entry crumbs that are neither first
583    /// nor last; pre-registered `item_id` crumbs never collapse.)
584    collapsible: Vec<bool>,
585    /// Menu-form per collapsible crumb, for the overflow `…` menu rows.
586    menu_forms: Rc<Vec<CrumbMenuForm>>,
587    /// The `[separator, …-button]` unit id (measured + gated on overflow).
588    ellipsis_unit_id: Option<WidgetId>,
589    trailing_id: Option<WidgetId>,
590    root_child_id: Option<WidgetId>,
591    /// Cached flags to avoid redundant signal writes.
592    last_flags: RefCell<Vec<bool>>,
593}
594
595impl Breadcrumb {
596    /// Construct an empty breadcrumb trail. Add segments with
597    /// [`item`](Self::item) and [`item_id`](Self::item_id).
598    pub fn new() -> Self {
599        Self {
600            slots: Vec::new(),
601            trailing_slot: None,
602            label: None,
603            collapsed: Signal::new(Vec::new()),
604            is_overflowing: Signal::new(false),
605            unit_ids: Vec::new(),
606            collapsible: Vec::new(),
607            menu_forms: Rc::new(Vec::new()),
608            ellipsis_unit_id: None,
609            trailing_id: None,
610            root_child_id: None,
611            last_flags: RefCell::new(Vec::new()),
612        }
613    }
614
615    /// Accessible name for the `Navigation` landmark — distinguishes
616    /// this breadcrumb from other nav landmarks on the page
617    /// (e.g. "Files", "Settings"). Screen readers announce it as the
618    /// name of the landmark when it gains focus or is summoned.
619    pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
620        let ls: LocalizedString = text.into();
621        self.label = Some(ls);
622        self
623    }
624
625    /// Append a `BreadcrumbItem` segment to the trail. Items are rendered
626    /// in insertion order, separated by chevron glyphs. Middle items (neither
627    /// root nor current) may be collapsed into the `…` overflow menu.
628    pub fn item(mut self, item: BreadcrumbItem) -> Self {
629        self.slots
630            .push(BreadcrumbSlot::Entry(Box::new(BreadcrumbEntry {
631                label: item.label,
632                action: item.action,
633                current: item.current,
634                tooltip_text: item.tooltip_text,
635                rich_tooltip_source: item.rich_tooltip_source,
636                composite_tooltip_content: item.composite_tooltip_content,
637            })));
638        self
639    }
640
641    /// Insert a pre-registered widget as a breadcrumb segment slot.
642    /// The caller is responsible for the segment's visual + interaction.
643    /// Note: a pre-registered crumb never collapses into the overflow menu
644    /// (the breadcrumb has no label/action to synthesize a menu row from) —
645    /// it is treated like the root/current crumbs as always-visible.
646    pub fn item_id(mut self, id: WidgetId) -> Self {
647        self.slots.push(BreadcrumbSlot::Id(id));
648        self
649    }
650
651    /// Append a trailing widget after all segments, pushed to the far edge
652    /// by an intervening `Spacer`. Common uses: a search icon, refresh button,
653    /// or current-path copy button. When a trailing slot is set, the breadcrumb
654    /// spans the full proposed width.
655    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
656        self.trailing_slot = Some(PendingChild::Deferred(Box::new(widget)));
657        self
658    }
659
660    /// Same as [`trailing_slot`](Self::trailing_slot) but accepts a
661    /// pre-registered `WidgetId` instead of an inline widget.
662    pub fn trailing_slot_id(mut self, id: WidgetId) -> Self {
663        self.trailing_slot = Some(PendingChild::Id(id));
664        self
665    }
666
667    /// Reactive signal that is `true` whenever any crumb is collapsed into the
668    /// overflow `…` menu — for adaptive chrome.
669    pub fn is_overflowing(&self) -> Signal<bool> {
670        self.is_overflowing.clone()
671    }
672}
673
674impl Default for Breadcrumb {
675    fn default() -> Self {
676        Self::new()
677    }
678}
679
680impl std::fmt::Debug for Breadcrumb {
681    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682        f.debug_struct("Breadcrumb")
683            .field("item_count", &self.slots.len())
684            .finish()
685    }
686}
687
688impl Widget for Breadcrumb {
689    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
690        let slots = std::mem::take(&mut self.slots);
691        let n = slots.len();
692
693        let mut unit_ids: Vec<WidgetId> = Vec::with_capacity(n);
694        let mut collapsible: Vec<bool> = Vec::with_capacity(n);
695        let mut menu_forms: Vec<CrumbMenuForm> = Vec::new();
696
697        for (i, slot) in slots.into_iter().enumerate() {
698            let is_first = i == 0;
699            let is_last = i + 1 == n;
700
701            // Resolve the slot to a segment id + (for Entry slots) a menu form.
702            let (seg_id, form): (WidgetId, Option<(LocalizedString, Option<CommandFactory>)>) =
703                match slot {
704                    BreadcrumbSlot::Entry(entry) => {
705                        let action = entry.action;
706                        let seg = BreadcrumbSegment::new(
707                            entry.label.clone(),
708                            action.clone(),
709                            entry.current,
710                        )
711                        .with_tooltip(
712                            entry.tooltip_text,
713                            entry.rich_tooltip_source,
714                            entry.composite_tooltip_content,
715                        );
716                        (ctx.add(seg), Some((entry.label, action)))
717                    }
718                    BreadcrumbSlot::Id(id) => (id, None),
719                };
720
721            // A crumb can collapse only if it's an Entry and neither the root
722            // nor the current (last) crumb.
723            let can_collapse = form.is_some() && !is_first && !is_last;
724            collapsible.push(can_collapse);
725
726            // The unit is the segment, prefixed by a leading separator for
727            // every crumb after the first. The separator lives inside the unit
728            // so it hides together with its crumb — no dangling chevrons.
729            let unit_id = if is_first {
730                seg_id
731            } else {
732                let sep_id = ctx.add(BreadcrumbSeparator);
733                ctx.add(
734                    HStack::new()
735                        .spacing(0.0)
736                        .add_child(sep_id)
737                        .add_child(seg_id),
738                )
739            };
740            unit_ids.push(unit_id);
741
742            if can_collapse {
743                let collapsed = self.collapsed.clone();
744                ctx.visible_when(
745                    unit_id,
746                    collapsed.map(move |flags| flags.get(i).copied() != Some(true)),
747                );
748                if let Some((label, action)) = form {
749                    menu_forms.push(CrumbMenuForm {
750                        slot: i,
751                        label,
752                        action,
753                    });
754                }
755            }
756        }
757
758        self.collapsible = collapsible;
759        self.menu_forms = Rc::new(menu_forms);
760        self.collapsed.set(vec![false; n]);
761        *self.last_flags.borrow_mut() = vec![false; n];
762
763        // Overflow chevron: a `…` PopoverButton (HasPopup::Menu) whose content
764        // is a `MenuList` with one row per collapsible crumb, each gated via
765        // `item_when(collapsed[slot])`. Only currently-collapsed rows are shown
766        // (zero-height + nav-skipped otherwise), so the menu reconciles
767        // reactively as the trail resizes — no rebuild of the dormant popover.
768        let has_collapsible = self.collapsible.iter().any(|&c| c);
769        self.ellipsis_unit_id = if has_collapsible {
770            let menu_forms = self.menu_forms.clone();
771            let mut menu = MenuList::new();
772            for form in menu_forms.iter() {
773                let slot = form.slot;
774                let action = form.action.clone();
775                let mut row = MenuItem::new(form.label.clone()).enabled(action.is_some());
776                if let Some(act) = action {
777                    row = row.on_activate_fn(move |ctx| {
778                        act(ctx);
779                        ctx.dismiss_self_overlay_chain();
780                    });
781                }
782                let collapsed = self.collapsed.clone();
783                let visible = collapsed.map(move |flags| flags.get(slot).copied() == Some(true));
784                menu = menu.item_when(row, visible);
785            }
786
787            let trigger = Button::new(teksilo_i18n::lit!("…"))
788                .variant(ButtonVariant::Ghost)
789                .tooltip(teksilo_i18n::tr_widget!(breadcrumb_overflow()));
790            let chevron = PopoverButton::new(trigger)
791                .content(menu)
792                // `MenuList` self-chromes via the Menu `PopoverStyle`.
793                .bare()
794                .placement(OverlayPlacement::BelowPreferred)
795                .has_popup_kind(HasPopup::Menu);
796            let chevron_id = ctx.add(chevron);
797
798            let sep_id = ctx.add(BreadcrumbSeparator);
799            let unit_id = ctx.add(
800                HStack::new()
801                    .spacing(0.0)
802                    .add_child(sep_id)
803                    .add_child(chevron_id),
804            );
805            ctx.visible_when(unit_id, self.is_overflowing.clone());
806            Some(unit_id)
807        } else {
808            None
809        };
810
811        // Assemble the row: [root] [… (after root)] [crumb 1] … [current]
812        // [Spacer trailing?].
813        let mut row = HStack::new().spacing(0.0);
814        for (i, &uid) in unit_ids.iter().enumerate() {
815            row = row.add_child(uid);
816            if i == 0 {
817                if let Some(eu) = self.ellipsis_unit_id {
818                    row = row.add_child(eu);
819                }
820            }
821        }
822        self.unit_ids = unit_ids;
823
824        if let Some(trailing) = self.trailing_slot.take() {
825            let trailing_id = match trailing {
826                PendingChild::Id(id) => id,
827                PendingChild::Deferred(w) => ctx.add_boxed(w),
828            };
829            self.trailing_id = Some(trailing_id);
830            row = row.child(Spacer::new()).add_child(trailing_id);
831        }
832
833        let root_id = ctx.add(row);
834        self.root_child_id = Some(root_id);
835        vec![root_id]
836    }
837
838    fn layout_response(
839        &self,
840        proposal: SizeProposal,
841        ctx: &LayoutContext,
842    ) -> teksilo_core::widget::LayoutResponse {
843        let Some(root) = self.root_child_id else {
844            return proposal.resolve(0.0, 0.0).into();
845        };
846
847        // Natural width = sum of every crumb unit's INTRINSIC width (measured
848        // regardless of its current collapse state, so this is stable and the
849        // overflow decision can't oscillate). The `…` chevron is excluded — it
850        // only appears when something is already collapsed.
851        let probe = SizeProposal::unspecified();
852        let mut natural_w = 0.0_f32;
853        for &uid in &self.unit_ids {
854            if let Some(s) = ctx.measure_intrinsic(uid, probe) {
855                natural_w += s.width;
856            }
857        }
858        let has_trailing = self.trailing_id.is_some();
859        if let Some(tid) = self.trailing_id
860            && let Some(s) = ctx.measure_intrinsic(tid, probe)
861        {
862            natural_w += s.width;
863        }
864
865        // With a trailing slot the breadcrumb spans the offered width (the
866        // Spacer pushes the trailing control to the edge); otherwise it
867        // shrink-wraps to its content, clamped to the offered width so it never
868        // spills its container.
869        let width = if has_trailing {
870            proposal.width.unwrap_or(natural_w)
871        } else {
872            proposal
873                .width
874                .map(|w| natural_w.min(w))
875                .unwrap_or(natural_w)
876        };
877
878        let height = ctx
879            .child_size(root, proposal)
880            .map(|s| s.height)
881            .unwrap_or(BREADCRUMB_ITEM_HEIGHT);
882        Size::new(width, height).into()
883    }
884
885    fn place_children(
886        &self,
887        bounds: Rect,
888        _proposal: SizeProposal,
889        children: &mut [WidgetPlacement],
890        ctx: &LayoutContext,
891    ) {
892        for child in children.iter_mut() {
893            child.origin = bounds.origin();
894            child.size = bounds.size();
895        }
896
897        // Compute the collapse set from intrinsic widths (measured even while
898        // hidden) against the width left for the crumbs.
899        let probe = SizeProposal::unspecified();
900        let trailing_w = self
901            .trailing_id
902            .and_then(|tid| ctx.measure_intrinsic(tid, probe))
903            .map(|s| s.width)
904            .unwrap_or(0.0);
905        let avail = (bounds.width - trailing_w).max(0.0);
906
907        let unit_w: Vec<f32> = self
908            .unit_ids
909            .iter()
910            .map(|&uid| {
911                ctx.measure_intrinsic(uid, probe)
912                    .map(|s| s.width)
913                    .unwrap_or(0.0)
914            })
915            .collect();
916        let ellipsis_w = self
917            .ellipsis_unit_id
918            .and_then(|eu| ctx.measure_intrinsic(eu, probe))
919            .map(|s| s.width)
920            .unwrap_or(0.0);
921
922        let flags = compute_breadcrumb_overflow(avail, &unit_w, &self.collapsible, ellipsis_w);
923
924        if *self.last_flags.borrow() != flags {
925            *self.last_flags.borrow_mut() = flags.clone();
926            let any = flags.iter().any(|&c| c);
927            self.collapsed.set(flags);
928            if self.is_overflowing.get() != any {
929                self.is_overflowing.set(any);
930            }
931        }
932    }
933
934    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
935        builder.set_role(teksilo_core::accesskit::Role::Navigation);
936        if let Some(ref label) = self.label {
937            builder.set_name(label.clone());
938        }
939    }
940
941    fn children(&self) -> Vec<WidgetId> {
942        self.root_child_id.into_iter().collect()
943    }
944}
945
946/// Decide which crumbs collapse into the overflow `…` menu.
947///
948/// Returns a per-slot `collapsed` flag (`true` = hidden). The root and current
949/// crumbs (and any non-collapsible pre-registered crumb) are kept; collapsible
950/// crumbs are hidden from the **left-middle outward** (lowest index first) until
951/// the shown crumbs — plus the `…` chevron once anything is hidden — fit in
952/// `avail`. If even the kept crumbs + chevron don't fit, the remainder overflows
953/// residually (nothing left to collapse).
954fn compute_breadcrumb_overflow(
955    avail: f32,
956    unit_w: &[f32],
957    collapsible: &[bool],
958    ellipsis_w: f32,
959) -> Vec<bool> {
960    let n = unit_w.len();
961    let mut collapsed = vec![false; n];
962    if n == 0 {
963        return collapsed;
964    }
965    let full: f32 = unit_w.iter().sum();
966    if full <= avail + 0.5 {
967        return collapsed; // everything fits — no chevron
968    }
969    loop {
970        let any_hidden = collapsed.iter().any(|&c| c);
971        let shown: f32 = (0..n)
972            .filter(|&i| !collapsed[i])
973            .map(|i| unit_w[i])
974            .sum::<f32>()
975            + if any_hidden { ellipsis_w } else { 0.0 };
976        if shown <= avail + 0.5 {
977            break;
978        }
979        match (0..n).find(|&i| collapsible[i] && !collapsed[i]) {
980            Some(i) => collapsed[i] = true,
981            None => break, // nothing left to collapse — residual overflow
982        }
983    }
984    collapsed
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use teksilo_canvas::MockTextBackend;
991    use teksilo_core::widget_tree::WidgetTree;
992    use teksilo_i18n::lit;
993
994    fn themed_tree() -> WidgetTree {
995        WidgetTree::new()
996            .with_theme(teksilo_core::presets::intui::light())
997            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
998    }
999
1000    fn trail(n: usize) -> Breadcrumb {
1001        let mut bc = Breadcrumb::new();
1002        for i in 0..n {
1003            let last = i + 1 == n;
1004            let item = if last {
1005                BreadcrumbItem::current(lit!(format!("Crumb {i}")))
1006            } else {
1007                BreadcrumbItem::new(lit!(format!("Crumb {i}"))).on_activate_fn(|_| {})
1008            };
1009            bc = bc.item(item);
1010        }
1011        bc
1012    }
1013
1014    // ── tooltips ─────────────────────────────────────────────────────────────
1015
1016    #[test]
1017    fn breadcrumb_tooltip_appears_after_the_hover_delay() {
1018        // Regression: `BreadcrumbItem::tooltip()` / `.rich_tooltip*()` /
1019        // `.composite_tooltip()` set their fields, but `Breadcrumb::item()`
1020        // copied only label/action/current into the entry — so all four
1021        // setters were silently dead. Nothing in this module hovered a
1022        // segment, so nothing caught it.
1023        let mut tree = themed_tree();
1024        let bc = Breadcrumb::new()
1025            .item(BreadcrumbItem::new(lit!("Home")).on_activate_fn(|_| {}))
1026            .item(BreadcrumbItem::current(lit!("Here")).tooltip(lit!("Where you are")));
1027        tree.add(bc);
1028        tree.layout(SizeProposal::exact(400.0, 60.0));
1029
1030        let segment = tree
1031            .find_by_label("Here")
1032            .expect("the current crumb renders");
1033        tree.pointer_move(tree.bounds(segment).center());
1034        assert!(
1035            tree.active_overlays().is_empty(),
1036            "must wait for the hover delay"
1037        );
1038
1039        let delay = tree.theme().motion.tooltip_delay;
1040        tree.advance_time(delay + std::time::Duration::from_millis(50));
1041        assert_eq!(
1042            tree.active_overlays().len(),
1043            1,
1044            "a breadcrumb crumb's tooltip must actually open"
1045        );
1046        assert!(tree.find_by_label("Where you are").is_some());
1047    }
1048
1049    #[test]
1050    fn a_crumb_contributes_a_tooltip_entry_only_when_it_has_one() {
1051        // Measured as a delta against the identical trail, so the count is
1052        // unaffected by whatever chrome the breadcrumb attaches on its own
1053        // (the overflow chevron carries a tooltip of its own).
1054        let mut bare = themed_tree();
1055        bare.add(trail(3));
1056        bare.layout(SizeProposal::exact(400.0, 60.0));
1057        let baseline = bare.tooltip_entry_count();
1058
1059        let mut tipped = themed_tree();
1060        let bc = Breadcrumb::new()
1061            .item(BreadcrumbItem::new(lit!("Crumb 0")).on_activate_fn(|_| {}))
1062            .item(BreadcrumbItem::new(lit!("Crumb 1")).on_activate_fn(|_| {}))
1063            .item(BreadcrumbItem::current(lit!("Crumb 2")).tooltip(lit!("Tip")));
1064        tipped.add(bc);
1065        tipped.layout(SizeProposal::exact(400.0, 60.0));
1066
1067        assert_eq!(
1068            tipped.tooltip_entry_count(),
1069            baseline + 1,
1070            "exactly the one crumb carrying a tooltip adds an entry"
1071        );
1072    }
1073
1074    // ── compute_breadcrumb_overflow ──────────────────────────────────────────
1075
1076    #[test]
1077    fn nothing_collapses_when_it_all_fits() {
1078        let flags = compute_breadcrumb_overflow(
1079            500.0,
1080            &[40.0, 40.0, 40.0, 40.0, 40.0],
1081            &[false, true, true, true, false],
1082            30.0,
1083        );
1084        assert_eq!(flags, vec![false; 5]);
1085    }
1086
1087    #[test]
1088    fn middle_collapses_from_the_left_keeping_root_and_current() {
1089        // full = 200 > 160. hide #1 → 40*4+30=190 > 160; hide #2 → 40*3+30=150 ≤ 160.
1090        let flags = compute_breadcrumb_overflow(
1091            160.0,
1092            &[40.0, 40.0, 40.0, 40.0, 40.0],
1093            &[false, true, true, true, false],
1094            30.0,
1095        );
1096        assert_eq!(flags, vec![false, true, true, false, false]);
1097    }
1098
1099    #[test]
1100    fn all_middle_collapses_when_very_narrow() {
1101        let flags = compute_breadcrumb_overflow(
1102            100.0,
1103            &[40.0, 40.0, 40.0, 40.0, 40.0],
1104            &[false, true, true, true, false],
1105            30.0,
1106        );
1107        assert_eq!(
1108            flags,
1109            vec![false, true, true, true, false],
1110            "root and current always survive; all middle collapse"
1111        );
1112    }
1113
1114    #[test]
1115    fn two_crumbs_never_collapse() {
1116        let flags = compute_breadcrumb_overflow(10.0, &[40.0, 40.0], &[false, false], 30.0);
1117        assert_eq!(flags, vec![false, false], "no collapsible middle to hide");
1118    }
1119
1120    // ── Integration ──────────────────────────────────────────────────────────
1121
1122    #[test]
1123    fn wide_trail_does_not_overflow_narrow_does() {
1124        let bc = trail(6);
1125        let overflowing = bc.is_overflowing();
1126        let mut tree = themed_tree();
1127        let _id = tree.add(bc);
1128
1129        tree.layout(SizeProposal::exact(2000.0, 30.0));
1130        assert!(!overflowing.get(), "a wide trail should not overflow");
1131
1132        tree.layout(SizeProposal::exact(160.0, 30.0));
1133        assert!(
1134            overflowing.get(),
1135            "a narrow trail should collapse middle crumbs into the … menu"
1136        );
1137
1138        tree.layout(SizeProposal::exact(2000.0, 30.0));
1139        assert!(
1140            !overflowing.get(),
1141            "re-widening restores all crumbs (intrinsic measure → no stale collapse)"
1142        );
1143    }
1144
1145    #[test]
1146    fn overflow_menu_rows_are_dormant_while_the_chevron_is_closed() {
1147        // The collapsed crumbs' menu rows live in the (closed) chevron popover;
1148        // they must not render until it opens.
1149        let bc = trail(6);
1150        let mut tree = themed_tree();
1151        let _id = tree.add(bc);
1152        tree.layout(SizeProposal::exact(160.0, 30.0)); // narrow → middle collapses
1153
1154        let active_menu_items: u32 = tree
1155            .widget_type_histogram()
1156            .iter()
1157            .filter(|(k, _)| k.contains("menu_item::MenuItem"))
1158            .map(|(_, v)| *v)
1159            .sum();
1160        assert_eq!(
1161            active_menu_items, 0,
1162            "overflow menu rows stay dormant until the … chevron opens"
1163        );
1164    }
1165
1166    #[test]
1167    fn builds_under_rtl() {
1168        use teksilo_core::environment::LayoutDirection;
1169        let bc = trail(5);
1170        let mut tree = themed_tree();
1171        tree.set_layout_direction(LayoutDirection::RightToLeft);
1172        let id = tree.add(bc);
1173        tree.layout(SizeProposal::exact(200.0, 30.0));
1174        assert!(tree.bounds(id).width > 0.0);
1175    }
1176}