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//!
34//! ## Touch and pen
35//!
36//! A crumb navigates on the release. It is as wide as its label, so a short one
37//! ("A / B / C", a drive letter) can land under the 24 dp target floor; it
38//! declares a `Widget::hit_outset` that makes the shortfall up between the
39//! pointer and the arena, so the trail's own geometry never moves. The current
40//! crumb and a trail with no navigation action declare none — a widened node
41//! that then refuses the press is a hole in whatever is behind it.
42
43use std::cell::RefCell;
44use std::rc::Rc;
45
46use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
47use teksilo_core::accessibility::AccessNodeBuilder;
48use teksilo_core::accesskit::HasPopup;
49use teksilo_core::binding::BindingLevel;
50use teksilo_core::build_context::BuildContext;
51use teksilo_core::environment::LayoutDirection;
52use teksilo_core::event::{EventResponse, Key, WidgetEvent};
53use teksilo_core::overlay::OverlayPlacement;
54use teksilo_core::signal::Signal;
55use teksilo_core::widget::{
56    CursorIcon, EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
57};
58use teksilo_core::widget_builder::HandlerSet;
59use teksilo_core::widget_id::WidgetId;
60use teksilo_tokens::{Color, CornerRadius, InputTokens};
61
62use crate::button::{Button, ButtonVariant};
63use crate::menu_item::MenuItem;
64use crate::menu_list::MenuList;
65use crate::popover_widget::PopoverButton;
66use crate::primitives::{HStack, IconWidget, Spacer};
67use teksilo_core::styles::FocusRingStroke;
68use teksilo_core::styles::density::spacing;
69use teksilo_i18n::LocalizedString;
70
71const FALLBACK_CHAR_WIDTH: f32 = 8.0;
72const FALLBACK_LINE_HEIGHT: f32 = 16.0;
73
74/// Shared activation closure. `Rc` (not `Box`) so a crumb's action can be
75/// fired from BOTH its inline segment AND its row in the overflow menu.
76type CommandFactory = Rc<dyn Fn(&mut EventContext)>;
77
78struct BreadcrumbEntry {
79    label: LocalizedString,
80    action: Option<CommandFactory>,
81    current: bool,
82    tooltip_text: Option<LocalizedString>,
83    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
84    composite_tooltip_content: Option<Box<dyn Widget>>,
85}
86
87impl std::fmt::Debug for BreadcrumbEntry {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("BreadcrumbEntry")
90            .field("label", &self.label)
91            .field("current", &self.current)
92            .finish()
93    }
94}
95
96/// Minimum height of a single breadcrumb segment in logical pixels.
97pub const BREADCRUMB_ITEM_HEIGHT: f32 = 20.0;
98/// Horizontal inner padding of each segment pill in logical pixels.
99pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL: f32 = 6.0;
100
101/// [`BREADCRUMB_ITEM_PADDING_HORIZONTAL`] scaled by the density's `spacing_factor`
102/// (1.00 / 1.15 / 1.30).
103pub fn breadcrumb_item_padding_horizontal(tokens: &InputTokens) -> f32 {
104    spacing(BREADCRUMB_ITEM_PADDING_HORIZONTAL, tokens)
105}
106/// Gap reserved for the chevron separator between adjacent segments.
107pub const BREADCRUMB_SEPARATOR_GAP: f32 = 4.0;
108
109/// [`BREADCRUMB_SEPARATOR_GAP`] scaled by the density's `spacing_factor`
110/// (1.00 / 1.15 / 1.30).
111pub fn breadcrumb_separator_gap(tokens: &InputTokens) -> f32 {
112    spacing(BREADCRUMB_SEPARATOR_GAP, tokens)
113}
114/// Corner radius of the interactive segment hover/focus rectangle.
115pub const BREADCRUMB_CORNER_RADIUS: f32 = 4.0;
116
117/// A single breadcrumb segment definition.
118pub struct BreadcrumbItem {
119    label: LocalizedString,
120    action: Option<CommandFactory>,
121    current: bool,
122    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
123    /// with the rich / composite slots — every setter clears the other two so
124    /// the last call wins.
125    tooltip_text: Option<LocalizedString>,
126    /// Optional rich tooltip source (registry key or inline content).
127    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
128    /// Optional composite tooltip body (arbitrary widget tree).
129    composite_tooltip_content: Option<Box<dyn Widget>>,
130}
131
132impl std::fmt::Debug for BreadcrumbItem {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("BreadcrumbItem")
135            .field("label", &self.label)
136            .field("current", &self.current)
137            .finish()
138    }
139}
140
141impl BreadcrumbItem {
142    /// Construct a non-current (navigable) breadcrumb segment.
143    pub fn new(label: impl Into<LocalizedString>) -> Self {
144        let ls: LocalizedString = label.into();
145        Self {
146            label: ls,
147            action: None,
148            current: false,
149            tooltip_text: None,
150            rich_tooltip_source: None,
151            composite_tooltip_content: None,
152        }
153    }
154
155    /// Construct the current (last) breadcrumb segment, announced
156    /// with `aria-current="page"`. Current segments are never
157    /// collapsed into the overflow `…` menu.
158    pub fn current(label: impl Into<LocalizedString>) -> Self {
159        let ls: LocalizedString = label.into();
160        Self {
161            label: ls,
162            action: None,
163            current: true,
164            tooltip_text: None,
165            rich_tooltip_source: None,
166            composite_tooltip_content: None,
167        }
168    }
169
170    /// Closure invoked on activation.
171    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
172        self.action = Some(Rc::new(f));
173        self
174    }
175
176    /// Attach a plain single-line tooltip to this breadcrumb segment, shown
177    /// after a hover delay. Clears any previously set rich or composite tooltip.
178    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
179        self.tooltip_text = Some(text.into());
180        self.rich_tooltip_source = None;
181        self.composite_tooltip_content = None;
182        self
183    }
184
185    /// Attach a rich tooltip to this breadcrumb segment, looked up by registry
186    /// key. Clears any previously set plain or composite tooltip.
187    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
188        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
189        self.tooltip_text = None;
190        self.composite_tooltip_content = None;
191        self
192    }
193
194    /// Attach a rich tooltip to this breadcrumb segment from inline content.
195    /// Clears any previously set plain or composite tooltip.
196    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
197        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
198        self.tooltip_text = None;
199        self.composite_tooltip_content = None;
200        self
201    }
202
203    /// Attach a composite tooltip (arbitrary widget tree) to this breadcrumb
204    /// segment. Clears any previously set plain or rich tooltip.
205    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
206        self.composite_tooltip_content = Some(Box::new(content));
207        self.tooltip_text = None;
208        self.rich_tooltip_source = None;
209        self
210    }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214enum SegmentInteraction {
215    Idle,
216    Hovered,
217    Focused,
218}
219
220struct BreadcrumbSegment {
221    label: LocalizedString,
222    action: Option<CommandFactory>,
223    current: bool,
224    interaction: Signal<SegmentInteraction>,
225    /// The crumb's laid-out size, recorded at paint so `hit_outset` can ask
226    /// whether *this* crumb fell short. A crumb is text-width: "A / B / C"
227    /// makes three of them narrower than a finger.
228    painted_size: std::cell::Cell<Size>,
229    /// Whether this crumb navigates, recorded so it survives mounting.
230    ///
231    /// [`is_interactive`](Self::is_interactive) asks whether `action` is still
232    /// present, and `build` **moves** it out into the tap / key / AT closures.
233    /// So that question answers `false` for every crumb that has been built,
234    /// and anything consulted after mount — the hit outset is, on every press —
235    /// has to ask this instead.
236    navigates: std::cell::Cell<bool>,
237    tooltip_text: Option<LocalizedString>,
238    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
239    composite_tooltip_content: Option<Box<dyn Widget>>,
240}
241
242impl std::fmt::Debug for BreadcrumbSegment {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("BreadcrumbSegment")
245            .field("label", &self.label)
246            .field("current", &self.current)
247            .field("interaction", &self.interaction.get())
248            .finish()
249    }
250}
251
252impl BreadcrumbSegment {
253    fn new(label: LocalizedString, action: Option<CommandFactory>, current: bool) -> Self {
254        let navigates = !current && action.is_some();
255        Self {
256            label,
257            action,
258            current,
259            navigates: std::cell::Cell::new(navigates),
260            interaction: Signal::new(SegmentInteraction::Idle),
261            painted_size: std::cell::Cell::new(Size::ZERO),
262            tooltip_text: None,
263            rich_tooltip_source: None,
264            composite_tooltip_content: None,
265        }
266    }
267
268    /// Move a [`BreadcrumbItem`]'s tooltip slots onto the built segment.
269    /// The three are mutually exclusive by construction (every `BreadcrumbItem`
270    /// setter clears the other two), so at most one is ever `Some`.
271    fn with_tooltip(
272        mut self,
273        tooltip_text: Option<LocalizedString>,
274        rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
275        composite_tooltip_content: Option<Box<dyn Widget>>,
276    ) -> Self {
277        self.tooltip_text = tooltip_text;
278        self.rich_tooltip_source = rich_tooltip_source;
279        self.composite_tooltip_content = composite_tooltip_content;
280        self
281    }
282
283    fn is_interactive(&self) -> bool {
284        !self.current && self.action.is_some()
285    }
286
287    fn estimate_width(&self, ctx: &LayoutContext) -> f32 {
288        let pad_h = breadcrumb_item_padding_horizontal(&ctx.theme.input);
289        let envelope = ctx.theme.shape.focus_ring_envelope();
290        let resolved = self.label.resolve_now();
291        let text_width = if let Some(backend) = ctx.text_backend {
292            backend
293                .borrow_mut()
294                .layout_single_line(&resolved, &ctx.theme.typography.small, None)
295                .width
296        } else {
297            resolved.len() as f32 * FALLBACK_CHAR_WIDTH
298        };
299        text_width + pad_h * 2.0 + envelope * 2.0
300    }
301}
302
303impl Widget for BreadcrumbSegment {
304    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
305        let self_id = ctx.self_id();
306        let interaction = ctx.signal(SegmentInteraction::Idle);
307        let registry = ctx.binding_registry();
308        interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
309        // Locale changes can alter the resolved label (and its width), so
310        // re-measure + repaint this custom-painted segment on locale switch.
311        ctx.locale_signal()
312            .bind_to(self_id, registry, BindingLevel::Relayout);
313        self.interaction = interaction.clone();
314
315        let interactive = self.is_interactive();
316        let action = self.action.take();
317        let action_for_tap = action.clone();
318        let action_for_key = action.clone();
319        let action_for_access = action;
320
321        let handler_set = HandlerSet::new()
322            .on_tap({
323                let interaction = interaction.clone();
324                move |_pos, ctx: &mut EventContext| {
325                    if !interactive {
326                        return;
327                    }
328                    if let Some(ref action) = action_for_tap {
329                        action(ctx);
330                    }
331                    interaction.set(SegmentInteraction::Hovered);
332                }
333            })
334            .on_hover({
335                let interaction = interaction.clone();
336                move |entered: bool, _ctx: &mut EventContext| {
337                    if !interactive {
338                        interaction.set(SegmentInteraction::Idle);
339                        return;
340                    }
341                    if interaction.get() == SegmentInteraction::Focused {
342                        return;
343                    }
344                    interaction.set(if entered {
345                        SegmentInteraction::Hovered
346                    } else {
347                        SegmentInteraction::Idle
348                    });
349                }
350            })
351            .on_focus({
352                let interaction = interaction.clone();
353                move |gained: bool, _ctx: &mut EventContext| {
354                    if !interactive {
355                        interaction.set(SegmentInteraction::Idle);
356                        return;
357                    }
358                    interaction.set(if gained {
359                        SegmentInteraction::Focused
360                    } else {
361                        SegmentInteraction::Idle
362                    });
363                }
364            })
365            .on_key({
366                let interaction = interaction.clone();
367                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
368                    if !interactive {
369                        return EventResponse::Ignored;
370                    }
371                    match event {
372                        WidgetEvent::KeyDown {
373                            key: Key::Enter | Key::Space,
374                            ..
375                        } => {
376                            if let Some(ref action) = action_for_key {
377                                action(ctx);
378                            }
379                            interaction.set(SegmentInteraction::Focused);
380                            EventResponse::Handled
381                        }
382                        _ => EventResponse::Ignored,
383                    }
384                }
385            })
386            .on_access_action(move |action, ctx: &mut EventContext| {
387                if interactive && action == teksilo_core::accesskit::Action::Click {
388                    if let Some(ref action) = action_for_access {
389                        action(ctx);
390                    }
391                    EventResponse::Handled
392                } else {
393                    EventResponse::Ignored
394                }
395            })
396            .focusable(interactive)
397            .cursor(if interactive {
398                CursorIcon::Pointer
399            } else {
400                CursorIcon::Default
401            });
402
403        ctx.apply_self_handlers(handler_set);
404
405        // Attach the tooltip carried over from `BreadcrumbItem`. The segment
406        // paints itself and returns no children, so it is its own anchor.
407        // Default `Below` placement: the crumbs sit in one non-wrapping row, so
408        // a tip under one covers no sibling crumb.
409        if let Some(content) = self.composite_tooltip_content.take() {
410            let delay = ctx.theme().motion.tooltip_delay_heavy;
411            crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
412        } else if let Some(source) = self.rich_tooltip_source.take() {
413            let delay = ctx.theme().motion.tooltip_delay;
414            crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
415        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
416            let delay = ctx.theme().motion.tooltip_delay;
417            crate::tooltip::attach_plain_tooltip(ctx, self_id, tooltip_text, delay);
418        }
419
420        Vec::new()
421    }
422
423    fn layout_response(
424        &self,
425        proposal: SizeProposal,
426        ctx: &LayoutContext,
427    ) -> teksilo_core::widget::LayoutResponse {
428        let envelope = ctx.theme.shape.focus_ring_envelope();
429        let width = proposal.width.unwrap_or_else(|| self.estimate_width(ctx));
430        let text_height = if let Some(backend) = ctx.text_backend {
431            backend
432                .borrow_mut()
433                .layout_single_line(&self.label.resolve_now(), &ctx.theme.typography.small, None)
434                .height
435        } else {
436            FALLBACK_LINE_HEIGHT
437        };
438        let visual_h = text_height.max(BREADCRUMB_ITEM_HEIGHT);
439        Size::new(width, visual_h + envelope * 2.0).into()
440    }
441
442    /// A crumb is as wide as its label, so a short one ("A / B / C", a drive
443    /// letter, a one-word folder) can land under the 24 dp floor on the axis
444    /// that matters most — and it cannot grow, because the crumbs and their
445    /// chevrons are laid out edge to edge and widening the paint would move the
446    /// whole trail. The shortfall is made up between the pointer and the arena.
447    ///
448    /// Zero for the current crumb and for a trail with no navigation action:
449    /// neither takes a press, and widening a node that then ignores one is a
450    /// hole punched in whatever is behind it.
451    fn hit_outset(
452        &self,
453        kind: teksilo_tokens::PointerKind,
454        tokens: &teksilo_tokens::InputTokens,
455    ) -> teksilo_canvas::EdgeInsets {
456        if !self.navigates.get() {
457            return teksilo_canvas::EdgeInsets::ZERO;
458        }
459        crate::button::target_outset(self.painted_size.get(), kind, tokens)
460    }
461
462    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
463        self.painted_size.set(bounds.size());
464        let colors = &ctx.theme.colors;
465        let shape = &ctx.theme.shape;
466        let envelope = shape.focus_ring_envelope();
467        let interaction = self.interaction.get();
468        // Not `is_interactive()`: `build` has moved the action out by now.
469        let interactive = self.navigates.get();
470
471        // Visual bounds — inset by the focus-ring envelope.
472        let visual = bounds.inset(envelope, envelope, envelope, envelope);
473
474        if interactive {
475            let background = if interaction == SegmentInteraction::Hovered {
476                colors.accent.with_alpha(0.08)
477            } else if interaction == SegmentInteraction::Focused {
478                colors.accent.with_alpha(0.12)
479            } else {
480                Color::TRANSPARENT
481            };
482            if background.a() > 0.0 {
483                canvas.fill_rounded_rect(
484                    visual,
485                    CornerRadius::uniform(BREADCRUMB_CORNER_RADIUS),
486                    background,
487                );
488            }
489            // Focus ring: drawn around the visual, inside the reserved
490            // envelope. A negative offset lays it over the visual's edge.
491            if interaction == SegmentInteraction::Focused {
492                let ring = FocusRingStroke::around(visual, BREADCRUMB_CORNER_RADIUS, shape);
493                canvas.stroke_rounded_rect(
494                    ring.rect,
495                    CornerRadius::uniform(ring.corner_radius),
496                    colors.focus_ring,
497                    shape.focus_ring_width,
498                );
499            }
500        }
501
502        let text_color = if self.current {
503            colors.text_primary
504        } else if interactive && interaction == SegmentInteraction::Hovered {
505            colors.accent_hover
506        } else if interactive {
507            colors.accent
508        } else {
509            colors.text_secondary
510        };
511
512        let pad_h = breadcrumb_item_padding_horizontal(&ctx.theme.input);
513        let text_bounds = Rect::new(
514            visual.x + pad_h,
515            visual.y,
516            (visual.width - pad_h * 2.0).max(0.0),
517            visual.height,
518        );
519        canvas.draw_text(
520            &self.label.resolve_now(),
521            text_bounds,
522            &ctx.theme.typography.small,
523            text_color,
524        );
525    }
526
527    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
528        // Every crumb keeps Role::Link — ARIA convention is that the
529        // current page is still announced as a link, just tagged with
530        // `aria-current="page"` so screen readers say "current page,
531        // <label>". Replaces the earlier Label-role + synthesized
532        // i18n `set_value` workaround which didn't map to a standard
533        // ARIA pattern.
534        builder.set_role(teksilo_core::accesskit::Role::Link);
535        builder.set_name(self.label.resolve_now());
536        if self.current {
537            builder.set_aria_current(teksilo_core::accesskit::AriaCurrent::Page);
538        } else if self.navigates.get() {
539            // Not `is_interactive()`: `build` has moved the action out by now.
540            builder.add_action(teksilo_core::accesskit::Action::Click);
541            builder.add_action(teksilo_core::accesskit::Action::Focus);
542        }
543    }
544}
545
546#[derive(Debug)]
547struct BreadcrumbSeparator;
548
549impl Widget for BreadcrumbSeparator {
550    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
551        // Repaint on locale change so the chevron can flip with the layout
552        // direction (it points toward the next crumb: right in LTR, left in
553        // RTL).
554        let self_id = ctx.self_id();
555        let registry = ctx.binding_registry();
556        ctx.locale_signal()
557            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
558        Vec::new()
559    }
560
561    fn layout_response(
562        &self,
563        _proposal: SizeProposal,
564        ctx: &LayoutContext,
565    ) -> teksilo_core::widget::LayoutResponse {
566        Size::new(
567            breadcrumb_separator_gap(&ctx.theme.input) * 3.0,
568            BREADCRUMB_ITEM_HEIGHT,
569        )
570        .into()
571    }
572
573    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
574        let size = 10.0;
575        let icon_bounds = Rect::new(
576            bounds.x + (bounds.width - size) / 2.0,
577            bounds.y + (bounds.height - size) / 2.0,
578            size,
579            size,
580        );
581        // Role-based: IconWidget resolves against the current theme at paint,
582        // so this stays reactive across theme switches. The chevron mirrors
583        // under RTL — it always points toward the *next* crumb.
584        let icon = if ctx.layout_direction == LayoutDirection::RightToLeft {
585            IconWidget::chevron_left(size)
586        } else {
587            IconWidget::chevron_right(size)
588        }
589        .color(teksilo_tokens::TextRole::Secondary);
590        icon.paint(icon_bounds, canvas, ctx);
591    }
592
593    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
594        // Decorative chevron between crumbs. Screen readers would
595        // otherwise enumerate a generic container between every pair
596        // of links; `set_hidden()` keeps the node in the layout tree
597        // but removes it from the platform a11y tree.
598        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
599        builder.set_hidden();
600    }
601}
602
603enum BreadcrumbSlot {
604    Entry(Box<BreadcrumbEntry>),
605    Id(WidgetId),
606}
607
608/// Menu-form of a collapsible crumb — the row shown in the overflow `…` menu.
609struct CrumbMenuForm {
610    slot: usize,
611    label: LocalizedString,
612    action: Option<CommandFactory>,
613}
614
615/// A breadcrumb navigation row with **automatic overflow**: when the trail is
616/// too wide, the middle crumbs collapse into a trailing-of-root `…` menu while
617/// the root and the current (last) crumb stay visible — the standard breadcrumb
618/// collapse (Windows Explorer / web breadcrumbs / macOS path bar).
619pub struct Breadcrumb {
620    slots: Vec<BreadcrumbSlot>,
621    trailing_slot: Option<PendingChild>,
622    label: Option<LocalizedString>,
623
624    // Reactive state.
625    /// Per-slot collapsed flag (`true` = hidden in the `…` menu). Only
626    /// collapsible slots are ever set; index-aligned with the slots.
627    collapsed: Signal<Vec<bool>>,
628    /// Whether any crumb is currently collapsed (drives the chevron).
629    is_overflowing: Signal<bool>,
630
631    // Build state.
632    /// Per-slot "unit" id (the slot's segment, plus its leading separator for
633    /// slots after the first) — measured to compute overflow.
634    unit_ids: Vec<WidgetId>,
635    /// Per-slot: can this crumb collapse? (Entry crumbs that are neither first
636    /// nor last; pre-registered `item_id` crumbs never collapse.)
637    collapsible: Vec<bool>,
638    /// Menu-form per collapsible crumb, for the overflow `…` menu rows.
639    menu_forms: Rc<Vec<CrumbMenuForm>>,
640    /// The `[separator, …-button]` unit id (measured + gated on overflow).
641    ellipsis_unit_id: Option<WidgetId>,
642    trailing_id: Option<WidgetId>,
643    root_child_id: Option<WidgetId>,
644    /// Cached flags to avoid redundant signal writes.
645    last_flags: RefCell<Vec<bool>>,
646}
647
648impl Breadcrumb {
649    /// Construct an empty breadcrumb trail. Add segments with
650    /// [`item`](Self::item) and [`item_id`](Self::item_id).
651    pub fn new() -> Self {
652        Self {
653            slots: Vec::new(),
654            trailing_slot: None,
655            label: None,
656            collapsed: Signal::new(Vec::new()),
657            is_overflowing: Signal::new(false),
658            unit_ids: Vec::new(),
659            collapsible: Vec::new(),
660            menu_forms: Rc::new(Vec::new()),
661            ellipsis_unit_id: None,
662            trailing_id: None,
663            root_child_id: None,
664            last_flags: RefCell::new(Vec::new()),
665        }
666    }
667
668    /// Accessible name for the `Navigation` landmark — distinguishes
669    /// this breadcrumb from other nav landmarks on the page
670    /// (e.g. "Files", "Settings"). Screen readers announce it as the
671    /// name of the landmark when it gains focus or is summoned.
672    pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
673        let ls: LocalizedString = text.into();
674        self.label = Some(ls);
675        self
676    }
677
678    /// Append a `BreadcrumbItem` segment to the trail. Items are rendered
679    /// in insertion order, separated by chevron glyphs. Middle items (neither
680    /// root nor current) may be collapsed into the `…` overflow menu.
681    pub fn item(mut self, item: BreadcrumbItem) -> Self {
682        self.slots
683            .push(BreadcrumbSlot::Entry(Box::new(BreadcrumbEntry {
684                label: item.label,
685                action: item.action,
686                current: item.current,
687                tooltip_text: item.tooltip_text,
688                rich_tooltip_source: item.rich_tooltip_source,
689                composite_tooltip_content: item.composite_tooltip_content,
690            })));
691        self
692    }
693
694    /// Append several `BreadcrumbItem` segments from an iterator, in order.
695    ///
696    /// The loop form of [`item`](Self::item), and the usual one: a trail is
697    /// normally walked out of a path rather than written crumb by crumb.
698    pub fn items(self, items: impl IntoIterator<Item = BreadcrumbItem>) -> Self {
699        items.into_iter().fold(self, Self::item)
700    }
701
702    /// Insert a pre-registered widget as a breadcrumb segment slot.
703    /// The caller is responsible for the segment's visual + interaction.
704    /// Note: a pre-registered crumb never collapses into the overflow menu
705    /// (the breadcrumb has no label/action to synthesize a menu row from) —
706    /// it is treated like the root/current crumbs as always-visible.
707    pub fn item_id(mut self, id: WidgetId) -> Self {
708        self.slots.push(BreadcrumbSlot::Id(id));
709        self
710    }
711
712    /// Insert several pre-registered widgets as breadcrumb segment slots.
713    ///
714    /// The id-carrying twin of [`items`](Self::items). Like `item_id`, none of
715    /// these crumbs ever collapses into the overflow menu.
716    pub fn item_ids(self, ids: impl IntoIterator<Item = WidgetId>) -> Self {
717        ids.into_iter().fold(self, Self::item_id)
718    }
719
720    /// Append a trailing widget after all segments, pushed to the far edge
721    /// by an intervening `Spacer`. Common uses: a search icon, refresh button,
722    /// or current-path copy button. When a trailing slot is set, the breadcrumb
723    /// spans the full proposed width.
724    pub fn trailing_slot(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
725        self.trailing_slot = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
726        self
727    }
728
729    /// Reactive signal that is `true` whenever any crumb is collapsed into the
730    /// overflow `…` menu — for adaptive chrome.
731    pub fn is_overflowing(&self) -> Signal<bool> {
732        self.is_overflowing.clone()
733    }
734}
735
736impl Default for Breadcrumb {
737    fn default() -> Self {
738        Self::new()
739    }
740}
741
742impl std::fmt::Debug for Breadcrumb {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        f.debug_struct("Breadcrumb")
745            .field("item_count", &self.slots.len())
746            .finish()
747    }
748}
749
750impl Widget for Breadcrumb {
751    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
752        let slots = std::mem::take(&mut self.slots);
753        let n = slots.len();
754
755        let mut unit_ids: Vec<WidgetId> = Vec::with_capacity(n);
756        let mut collapsible: Vec<bool> = Vec::with_capacity(n);
757        let mut menu_forms: Vec<CrumbMenuForm> = Vec::new();
758
759        for (i, slot) in slots.into_iter().enumerate() {
760            let is_first = i == 0;
761            let is_last = i + 1 == n;
762
763            // Resolve the slot to a segment id + (for Entry slots) a menu form.
764            let (seg_id, form): (WidgetId, Option<(LocalizedString, Option<CommandFactory>)>) =
765                match slot {
766                    BreadcrumbSlot::Entry(entry) => {
767                        let action = entry.action;
768                        let seg = BreadcrumbSegment::new(
769                            entry.label.clone(),
770                            action.clone(),
771                            entry.current,
772                        )
773                        .with_tooltip(
774                            entry.tooltip_text,
775                            entry.rich_tooltip_source,
776                            entry.composite_tooltip_content,
777                        );
778                        (ctx.add(seg), Some((entry.label, action)))
779                    }
780                    BreadcrumbSlot::Id(id) => (id, None),
781                };
782
783            // A crumb can collapse only if it's an Entry and neither the root
784            // nor the current (last) crumb.
785            let can_collapse = form.is_some() && !is_first && !is_last;
786            collapsible.push(can_collapse);
787
788            // The unit is the segment, prefixed by a leading separator for
789            // every crumb after the first. The separator lives inside the unit
790            // so it hides together with its crumb — no dangling chevrons.
791            let unit_id = if is_first {
792                seg_id
793            } else {
794                let sep_id = ctx.add(BreadcrumbSeparator);
795                ctx.add(HStack::new().spacing(0.0).child(sep_id).child(seg_id))
796            };
797            unit_ids.push(unit_id);
798
799            if can_collapse {
800                let collapsed = self.collapsed.clone();
801                ctx.visible_when(
802                    unit_id,
803                    collapsed.map(move |flags| flags.get(i).copied() != Some(true)),
804                );
805                if let Some((label, action)) = form {
806                    menu_forms.push(CrumbMenuForm {
807                        slot: i,
808                        label,
809                        action,
810                    });
811                }
812            }
813        }
814
815        self.collapsible = collapsible;
816        self.menu_forms = Rc::new(menu_forms);
817        self.collapsed.set(vec![false; n]);
818        *self.last_flags.borrow_mut() = vec![false; n];
819
820        // Overflow chevron: a `…` PopoverButton (HasPopup::Menu) whose content
821        // is a `MenuList` with one row per collapsible crumb, each gated via
822        // `item_when(collapsed[slot])`. Only currently-collapsed rows are shown
823        // (zero-height + nav-skipped otherwise), so the menu reconciles
824        // reactively as the trail resizes — no rebuild of the dormant popover.
825        let has_collapsible = self.collapsible.iter().any(|&c| c);
826        self.ellipsis_unit_id = if has_collapsible {
827            let menu_forms = self.menu_forms.clone();
828            let mut menu = MenuList::new();
829            for form in menu_forms.iter() {
830                let slot = form.slot;
831                let action = form.action.clone();
832                let mut row = MenuItem::new(form.label.clone()).enabled(action.is_some());
833                if let Some(act) = action {
834                    row = row.on_activate_fn(move |ctx| {
835                        act(ctx);
836                        ctx.dismiss_self_overlay_chain();
837                    });
838                }
839                let collapsed = self.collapsed.clone();
840                let visible = collapsed.map(move |flags| flags.get(slot).copied() == Some(true));
841                menu = menu.item_when(row, visible);
842            }
843
844            let trigger = Button::new(teksilo_i18n::lit!("…"))
845                .variant(ButtonVariant::Ghost)
846                .tooltip(teksilo_i18n::tr_widget!(breadcrumb_overflow()));
847            let chevron = PopoverButton::new(trigger)
848                .content(menu)
849                // `MenuList` self-chromes via the Menu `PopoverStyle`.
850                .bare()
851                .placement(OverlayPlacement::BelowPreferred)
852                .has_popup_kind(HasPopup::Menu);
853            let chevron_id = ctx.add(chevron);
854
855            let sep_id = ctx.add(BreadcrumbSeparator);
856            let unit_id = ctx.add(HStack::new().spacing(0.0).child(sep_id).child(chevron_id));
857            ctx.visible_when(unit_id, self.is_overflowing.clone());
858            Some(unit_id)
859        } else {
860            None
861        };
862
863        // Assemble the row: [root] [… (after root)] [crumb 1] … [current]
864        // [Spacer trailing?].
865        let mut row = HStack::new().spacing(0.0);
866        for (i, &uid) in unit_ids.iter().enumerate() {
867            row = row.child(uid);
868            if i == 0 {
869                if let Some(eu) = self.ellipsis_unit_id {
870                    row = row.child(eu);
871                }
872            }
873        }
874        self.unit_ids = unit_ids;
875
876        if let Some(trailing) = self.trailing_slot.take() {
877            let trailing_id = match trailing {
878                PendingChild::Id(id) => id,
879                PendingChild::Deferred(w) => ctx.add_boxed(w),
880            };
881            self.trailing_id = Some(trailing_id);
882            row = row.child(Spacer::new()).child(trailing_id);
883        }
884
885        let root_id = ctx.add(row);
886        self.root_child_id = Some(root_id);
887        vec![root_id]
888    }
889
890    fn layout_response(
891        &self,
892        proposal: SizeProposal,
893        ctx: &LayoutContext,
894    ) -> teksilo_core::widget::LayoutResponse {
895        let Some(root) = self.root_child_id else {
896            return proposal.resolve(0.0, 0.0).into();
897        };
898
899        // Natural width = sum of every crumb unit's INTRINSIC width (measured
900        // regardless of its current collapse state, so this is stable and the
901        // overflow decision can't oscillate). The `…` chevron is excluded — it
902        // only appears when something is already collapsed.
903        let probe = SizeProposal::unspecified();
904        let mut natural_w = 0.0_f32;
905        for &uid in &self.unit_ids {
906            if let Some(s) = ctx.measure_intrinsic(uid, probe) {
907                natural_w += s.width;
908            }
909        }
910        let has_trailing = self.trailing_id.is_some();
911        if let Some(tid) = self.trailing_id
912            && let Some(s) = ctx.measure_intrinsic(tid, probe)
913        {
914            natural_w += s.width;
915        }
916
917        // With a trailing slot the breadcrumb spans the offered width (the
918        // Spacer pushes the trailing control to the edge); otherwise it
919        // shrink-wraps to its content, clamped to the offered width so it never
920        // spills its container.
921        let width = if has_trailing {
922            proposal.width.unwrap_or(natural_w)
923        } else {
924            proposal
925                .width
926                .map(|w| natural_w.min(w))
927                .unwrap_or(natural_w)
928        };
929
930        let height = ctx
931            .child_size(root, proposal)
932            .map(|s| s.height)
933            .unwrap_or(BREADCRUMB_ITEM_HEIGHT);
934        Size::new(width, height).into()
935    }
936
937    fn place_children(
938        &self,
939        bounds: Rect,
940        _proposal: SizeProposal,
941        children: &mut [WidgetPlacement],
942        ctx: &LayoutContext,
943    ) {
944        for child in children.iter_mut() {
945            child.origin = bounds.origin();
946            child.size = bounds.size();
947        }
948
949        // Compute the collapse set from intrinsic widths (measured even while
950        // hidden) against the width left for the crumbs.
951        let probe = SizeProposal::unspecified();
952        let trailing_w = self
953            .trailing_id
954            .and_then(|tid| ctx.measure_intrinsic(tid, probe))
955            .map(|s| s.width)
956            .unwrap_or(0.0);
957        let avail = (bounds.width - trailing_w).max(0.0);
958
959        let unit_w: Vec<f32> = self
960            .unit_ids
961            .iter()
962            .map(|&uid| {
963                ctx.measure_intrinsic(uid, probe)
964                    .map(|s| s.width)
965                    .unwrap_or(0.0)
966            })
967            .collect();
968        let ellipsis_w = self
969            .ellipsis_unit_id
970            .and_then(|eu| ctx.measure_intrinsic(eu, probe))
971            .map(|s| s.width)
972            .unwrap_or(0.0);
973
974        let flags = compute_breadcrumb_overflow(avail, &unit_w, &self.collapsible, ellipsis_w);
975
976        if *self.last_flags.borrow() != flags {
977            *self.last_flags.borrow_mut() = flags.clone();
978            let any = flags.iter().any(|&c| c);
979            self.collapsed.set(flags);
980            if self.is_overflowing.get() != any {
981                self.is_overflowing.set(any);
982            }
983        }
984    }
985
986    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
987        builder.set_role(teksilo_core::accesskit::Role::Navigation);
988        if let Some(ref label) = self.label {
989            builder.set_name(label.clone());
990        }
991    }
992
993    fn children(&self) -> Vec<WidgetId> {
994        self.root_child_id.into_iter().collect()
995    }
996}
997
998/// Decide which crumbs collapse into the overflow `…` menu.
999///
1000/// Returns a per-slot `collapsed` flag (`true` = hidden). The root and current
1001/// crumbs (and any non-collapsible pre-registered crumb) are kept; collapsible
1002/// crumbs are hidden from the **left-middle outward** (lowest index first) until
1003/// the shown crumbs — plus the `…` chevron once anything is hidden — fit in
1004/// `avail`. If even the kept crumbs + chevron don't fit, the remainder overflows
1005/// residually (nothing left to collapse).
1006fn compute_breadcrumb_overflow(
1007    avail: f32,
1008    unit_w: &[f32],
1009    collapsible: &[bool],
1010    ellipsis_w: f32,
1011) -> Vec<bool> {
1012    let n = unit_w.len();
1013    let mut collapsed = vec![false; n];
1014    if n == 0 {
1015        return collapsed;
1016    }
1017    let full: f32 = unit_w.iter().sum();
1018    if full <= avail + 0.5 {
1019        return collapsed; // everything fits — no chevron
1020    }
1021    loop {
1022        let any_hidden = collapsed.iter().any(|&c| c);
1023        let shown: f32 = (0..n)
1024            .filter(|&i| !collapsed[i])
1025            .map(|i| unit_w[i])
1026            .sum::<f32>()
1027            + if any_hidden { ellipsis_w } else { 0.0 };
1028        if shown <= avail + 0.5 {
1029            break;
1030        }
1031        match (0..n).find(|&i| collapsible[i] && !collapsed[i]) {
1032            Some(i) => collapsed[i] = true,
1033            None => break, // nothing left to collapse — residual overflow
1034        }
1035    }
1036    collapsed
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use teksilo_canvas::MockTextBackend;
1043    use teksilo_core::widget_tree::WidgetTree;
1044    use teksilo_i18n::lit;
1045
1046    fn themed_tree() -> WidgetTree {
1047        WidgetTree::new()
1048            .with_theme(teksilo_core::presets::intui::light())
1049            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
1050    }
1051
1052    fn trail(n: usize) -> Breadcrumb {
1053        let mut bc = Breadcrumb::new();
1054        for i in 0..n {
1055            let last = i + 1 == n;
1056            let item = if last {
1057                BreadcrumbItem::current(lit!(format!("Crumb {i}")))
1058            } else {
1059                BreadcrumbItem::new(lit!(format!("Crumb {i}"))).on_activate_fn(|_| {})
1060            };
1061            bc = bc.item(item);
1062        }
1063        bc
1064    }
1065
1066    // ── focus ring ───────────────────────────────────────────────────────────
1067
1068    /// The focused crumb's ring and its visual (the focused background
1069    /// fill) under a theme whose `focus_ring_offset` is `offset` (width 2),
1070    /// each as `[x, y, width, height]`.
1071    fn crumb_ring_and_visual(offset: f32) -> ([f32; 4], [f32; 4]) {
1072        let mut theme = teksilo_core::presets::intui::light();
1073        theme.shape.focus_ring_offset = offset;
1074        theme.shape.focus_ring_width = 2.0;
1075        let ring_color = theme.colors.focus_ring.to_array();
1076        let fill_color = theme.colors.accent.with_alpha(0.12).to_array();
1077        let mut tree = WidgetTree::new()
1078            .with_theme(theme)
1079            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1080        tree.add(trail(2));
1081        tree.layout(SizeProposal::exact(400.0, 60.0));
1082        let crumb = tree
1083            .find_by_label("Crumb 0")
1084            .expect("the first crumb renders");
1085        tree.focus(crumb);
1086        tree.layout(SizeProposal::exact(400.0, 60.0));
1087        let frame = tree.render();
1088        let ring = frame
1089            .shapes
1090            .iter()
1091            .find(|s| s.color == ring_color && s.stroke_width > 0.0)
1092            .expect("a focused crumb strokes its ring")
1093            .screen;
1094        let visual = frame
1095            .shapes
1096            .iter()
1097            .find(|s| s.color == fill_color && s.stroke_width == 0.0)
1098            .expect("a focused crumb fills its visual")
1099            .screen;
1100        (ring, visual)
1101    }
1102
1103    #[test]
1104    fn a_negative_focus_ring_offset_lays_the_ring_over_the_crumb_edge() {
1105        let (outer, outer_visual) = crumb_ring_and_visual(2.0);
1106        let (inner, inner_visual) = crumb_ring_and_visual(-1.0);
1107        // Offset 2: centre line 3 dp outside the visual. Offset -1, width
1108        // 2: centred on the visual's edge.
1109        assert!((outer[2] - outer_visual[2] - 6.0).abs() < 1e-3);
1110        assert!((inner[2] - inner_visual[2]).abs() < 1e-3);
1111        assert!((inner[3] - inner_visual[3]).abs() < 1e-3);
1112
1113        let (deep, _) = crumb_ring_and_visual(-100.0);
1114        assert!(deep[2] >= 0.0 && deep[3] >= 0.0, "ring inverted: {deep:?}");
1115    }
1116
1117    // ── tooltips ─────────────────────────────────────────────────────────────
1118
1119    #[test]
1120    fn breadcrumb_tooltip_appears_after_the_hover_delay() {
1121        // Regression: `BreadcrumbItem::tooltip()` / `.rich_tooltip*()` /
1122        // `.composite_tooltip()` set their fields, but `Breadcrumb::item()`
1123        // copied only label/action/current into the entry — so all four
1124        // setters were silently dead. Nothing in this module hovered a
1125        // segment, so nothing caught it.
1126        let mut tree = themed_tree();
1127        let bc = Breadcrumb::new()
1128            .item(BreadcrumbItem::new(lit!("Home")).on_activate_fn(|_| {}))
1129            .item(BreadcrumbItem::current(lit!("Here")).tooltip(lit!("Where you are")));
1130        tree.add(bc);
1131        tree.layout(SizeProposal::exact(400.0, 60.0));
1132
1133        let segment = tree
1134            .find_by_label("Here")
1135            .expect("the current crumb renders");
1136        tree.pointer_move(tree.bounds(segment).center());
1137        assert!(
1138            tree.active_overlays().is_empty(),
1139            "must wait for the hover delay"
1140        );
1141
1142        let delay = tree.theme().motion.tooltip_delay;
1143        tree.advance_time(delay + std::time::Duration::from_millis(50));
1144        assert_eq!(
1145            tree.active_overlays().len(),
1146            1,
1147            "a breadcrumb crumb's tooltip must actually open"
1148        );
1149        assert!(tree.find_by_label("Where you are").is_some());
1150    }
1151
1152    #[test]
1153    fn a_crumb_contributes_a_tooltip_entry_only_when_it_has_one() {
1154        // Measured as a delta against the identical trail, so the count is
1155        // unaffected by whatever chrome the breadcrumb attaches on its own
1156        // (the overflow chevron carries a tooltip of its own).
1157        let mut bare = themed_tree();
1158        bare.add(trail(3));
1159        bare.layout(SizeProposal::exact(400.0, 60.0));
1160        let baseline = bare.tooltip_entry_count();
1161
1162        let mut tipped = themed_tree();
1163        let bc = Breadcrumb::new()
1164            .item(BreadcrumbItem::new(lit!("Crumb 0")).on_activate_fn(|_| {}))
1165            .item(BreadcrumbItem::new(lit!("Crumb 1")).on_activate_fn(|_| {}))
1166            .item(BreadcrumbItem::current(lit!("Crumb 2")).tooltip(lit!("Tip")));
1167        tipped.add(bc);
1168        tipped.layout(SizeProposal::exact(400.0, 60.0));
1169
1170        assert_eq!(
1171            tipped.tooltip_entry_count(),
1172            baseline + 1,
1173            "exactly the one crumb carrying a tooltip adds an entry"
1174        );
1175    }
1176
1177    // ── compute_breadcrumb_overflow ──────────────────────────────────────────
1178
1179    #[test]
1180    fn nothing_collapses_when_it_all_fits() {
1181        let flags = compute_breadcrumb_overflow(
1182            500.0,
1183            &[40.0, 40.0, 40.0, 40.0, 40.0],
1184            &[false, true, true, true, false],
1185            30.0,
1186        );
1187        assert_eq!(flags, vec![false; 5]);
1188    }
1189
1190    #[test]
1191    fn middle_collapses_from_the_left_keeping_root_and_current() {
1192        // full = 200 > 160. hide #1 → 40*4+30=190 > 160; hide #2 → 40*3+30=150 ≤ 160.
1193        let flags = compute_breadcrumb_overflow(
1194            160.0,
1195            &[40.0, 40.0, 40.0, 40.0, 40.0],
1196            &[false, true, true, true, false],
1197            30.0,
1198        );
1199        assert_eq!(flags, vec![false, true, true, false, false]);
1200    }
1201
1202    #[test]
1203    fn all_middle_collapses_when_very_narrow() {
1204        let flags = compute_breadcrumb_overflow(
1205            100.0,
1206            &[40.0, 40.0, 40.0, 40.0, 40.0],
1207            &[false, true, true, true, false],
1208            30.0,
1209        );
1210        assert_eq!(
1211            flags,
1212            vec![false, true, true, true, false],
1213            "root and current always survive; all middle collapse"
1214        );
1215    }
1216
1217    #[test]
1218    fn two_crumbs_never_collapse() {
1219        let flags = compute_breadcrumb_overflow(10.0, &[40.0, 40.0], &[false, false], 30.0);
1220        assert_eq!(flags, vec![false, false], "no collapsible middle to hide");
1221    }
1222
1223    // ── Integration ──────────────────────────────────────────────────────────
1224
1225    #[test]
1226    fn wide_trail_does_not_overflow_narrow_does() {
1227        let bc = trail(6);
1228        let overflowing = bc.is_overflowing();
1229        let mut tree = themed_tree();
1230        let _id = tree.add(bc);
1231
1232        tree.layout(SizeProposal::exact(2000.0, 30.0));
1233        assert!(!overflowing.get(), "a wide trail should not overflow");
1234
1235        tree.layout(SizeProposal::exact(160.0, 30.0));
1236        assert!(
1237            overflowing.get(),
1238            "a narrow trail should collapse middle crumbs into the … menu"
1239        );
1240
1241        tree.layout(SizeProposal::exact(2000.0, 30.0));
1242        assert!(
1243            !overflowing.get(),
1244            "re-widening restores all crumbs (intrinsic measure → no stale collapse)"
1245        );
1246    }
1247
1248    #[test]
1249    fn overflow_menu_rows_are_dormant_while_the_chevron_is_closed() {
1250        // The collapsed crumbs' menu rows live in the (closed) chevron popover;
1251        // they must not render until it opens.
1252        let bc = trail(6);
1253        let mut tree = themed_tree();
1254        let _id = tree.add(bc);
1255        tree.layout(SizeProposal::exact(160.0, 30.0)); // narrow → middle collapses
1256
1257        let active_menu_items: u32 = tree
1258            .widget_type_histogram()
1259            .iter()
1260            .filter(|(k, _)| k.contains("menu_item::MenuItem"))
1261            .map(|(_, v)| *v)
1262            .sum();
1263        assert_eq!(
1264            active_menu_items, 0,
1265            "overflow menu rows stay dormant until the … chevron opens"
1266        );
1267    }
1268
1269    #[test]
1270    fn builds_under_rtl() {
1271        use teksilo_core::environment::LayoutDirection;
1272        let bc = trail(5);
1273        let mut tree = themed_tree();
1274        tree.set_layout_direction(LayoutDirection::RightToLeft);
1275        let id = tree.add(bc);
1276        tree.layout(SizeProposal::exact(200.0, 30.0));
1277        assert!(tree.bounds(id).width > 0.0);
1278    }
1279
1280    /// A crumb clears the 24 dp floor at Compact whenever its label gives it
1281    /// the width, which is the usual case — and where it does not, the
1282    /// `hit_outset` on `BreadcrumbSegment` makes the shortfall up between the
1283    /// pointer and the arena rather than moving the trail. This pins the first
1284    /// half; the second is the shared `target_outset` contract, covered where
1285    /// it can be measured (`primitives::twist_arrow`).
1286    #[test]
1287    fn a_crumb_clears_the_conformance_floor_at_compact() {
1288        let theme = teksilo_core::presets::intui::light();
1289        let floor = theme.input.min_target_conformance;
1290        let mut tree = WidgetTree::new().with_theme(theme);
1291        let bc = tree.add(
1292            Breadcrumb::new()
1293                .item(BreadcrumbItem::new(lit!("A")).on_activate_fn(|_| {}))
1294                .item(BreadcrumbItem::new(lit!("Files"))),
1295        );
1296        tree.layout(SizeProposal::exact(300.0, 40.0));
1297        let _ = tree.render();
1298        let crumb = first_crumb(&tree, bc).expect("the trail has crumbs");
1299        assert!(
1300            crumb.height >= floor,
1301            "a Compact crumb measured {crumb:?}, under the {floor} dp floor",
1302        );
1303    }
1304
1305    /// A crumb that measures under the 24 dp floor, in a real trail, inside a
1306    /// path bar that takes taps of its own.
1307    ///
1308    /// The crumb is nameless — an icon-only root crumb, an unnamed drive —
1309    /// because that is the only label `MockTextBackend` can measure under the
1310    /// floor: it bills every character at a flat 8 dp and the segment adds 12
1311    /// dp of padding and 8 dp of focus-ring envelope, so even a one-letter
1312    /// label comes out at 28 dp. With a real proportional font at
1313    /// `typography.small`, a drive letter or a one-word folder lands in the
1314    /// same place, which is the case the module doc names.
1315    ///
1316    /// The bar is what makes this test discriminate. The miss-only slop pass
1317    /// only wins when the exact hit's bubble path carries no eligible handler,
1318    /// or when its candidate is strictly closer than that path's owner; the bar
1319    /// owns the press at distance zero, and a near-miss does not beat zero. So
1320    /// the only mechanism left that can carry a press beside the crumb onto it
1321    /// is `BreadcrumbSegment`'s own `hit_outset`.
1322    fn narrow_crumb_in_a_tappable_bar(
1323        hits: Rc<std::cell::Cell<u32>>,
1324        bar_taps: Rc<std::cell::Cell<u32>>,
1325    ) -> (WidgetTree, WidgetId) {
1326        use teksilo_core::widget_builder::WidgetBuilder;
1327
1328        let mut tree = themed_tree();
1329        let trail = tree.add(
1330            Breadcrumb::new()
1331                .item(
1332                    BreadcrumbItem::new(lit!("")).on_activate_fn(move |_| hits.set(hits.get() + 1)),
1333                )
1334                .item(BreadcrumbItem::new(lit!("Users")))
1335                .item(BreadcrumbItem::new(lit!("cyril"))),
1336        );
1337        let _bar = tree.add(
1338            crate::primitives::HStack::new()
1339                .child(trail)
1340                .on_tap(move |_e, _c| bar_taps.set(bar_taps.get() + 1)),
1341        );
1342        tree.layout(SizeProposal::exact(300.0, 40.0));
1343        let _ = tree.render();
1344        let crumb = first_segment_id(&tree, trail).expect("the trail has crumbs");
1345        (tree, crumb)
1346    }
1347
1348    /// The trail's segments, in reading order.
1349    fn segment_ids(tree: &WidgetTree, trail: WidgetId) -> Vec<WidgetId> {
1350        let mut found: Vec<(WidgetId, f32)> = Vec::new();
1351        let mut stack = vec![trail];
1352        while let Some(id) = stack.pop() {
1353            if tree
1354                .widget_type_name(id)
1355                .is_some_and(|n| n.contains("BreadcrumbSegment"))
1356            {
1357                found.push((id, tree.bounds(id).x));
1358            }
1359            stack.extend(tree.children(id).iter().copied());
1360        }
1361        found.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1362        found.into_iter().map(|(id, _)| id).collect()
1363    }
1364
1365    /// The leading, narrowest segment of the trail.
1366    fn first_segment_id(tree: &WidgetTree, trail: WidgetId) -> Option<WidgetId> {
1367        segment_ids(tree, trail).into_iter().next()
1368    }
1369
1370    /// A crumb that lands under the floor earns the shortfall between the
1371    /// pointer and the arena, so the trail's own geometry never moves.
1372    #[test]
1373    fn a_finger_just_outside_a_narrow_crumb_still_navigates() {
1374        use crate::button::press_test_support::{finger, touch};
1375        use teksilo_core::pointer::PointerPhase;
1376
1377        let hits = Rc::new(std::cell::Cell::new(0_u32));
1378        let bar_taps = Rc::new(std::cell::Cell::new(0_u32));
1379        let (mut tree, crumb) = narrow_crumb_in_a_tappable_bar(hits.clone(), bar_taps.clone());
1380        let b = tree.bounds(crumb);
1381        assert!(
1382            b.width < 24.0,
1383            "the crumb is meant to be under the floor, got {b:?}"
1384        );
1385        // 1.5 dp past the crumb, inside the 24 dp target the outset earns it.
1386        let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
1387        let id = finger();
1388        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1389        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
1390        assert_eq!(hits.get(), 1, "the crumb's outset did not take the press");
1391        assert_eq!(bar_taps.get(), 0, "and the bar must not have taken it too");
1392    }
1393
1394    /// The current crumb navigates nowhere, so it claims no widened target: a
1395    /// node that widens itself and then refuses the press is a hole punched in
1396    /// the path bar behind it.
1397    ///
1398    /// Read off the **mounted** crumbs, because the outset is derived from what
1399    /// each one painted — an unmounted segment has painted nothing and would
1400    /// report `ZERO` whatever the gate said.
1401    #[test]
1402    fn only_the_navigating_crumb_declares_an_outset() {
1403        use teksilo_tokens::PointerKind;
1404
1405        let mut tree = themed_tree();
1406        // Two nameless crumbs, so both land on the same 20 dp geometry: the
1407        // first navigates, the last is the current one.
1408        let trail = tree.add(
1409            Breadcrumb::new()
1410                .item(BreadcrumbItem::new(lit!("")).on_activate_fn(|_| {}))
1411                .item(BreadcrumbItem::new(lit!(""))),
1412        );
1413        tree.layout(SizeProposal::exact(300.0, 40.0));
1414        let _ = tree.render();
1415
1416        let crumbs = segment_ids(&tree, trail);
1417        assert_eq!(crumbs.len(), 2, "two crumbs, got {crumbs:?}");
1418        for id in &crumbs {
1419            assert!(
1420                tree.bounds(*id).width < 24.0,
1421                "premise: both crumbs are under the floor",
1422            );
1423        }
1424        assert_ne!(
1425            tree.widget_hit_outset(crumbs[0], PointerKind::Touch),
1426            teksilo_canvas::EdgeInsets::ZERO,
1427            "the navigating crumb earns its shortfall",
1428        );
1429        assert_eq!(
1430            tree.widget_hit_outset(crumbs[1], PointerKind::Touch),
1431            teksilo_canvas::EdgeInsets::ZERO,
1432            "the current crumb navigates nowhere and must claim nothing",
1433        );
1434    }
1435
1436    /// A built crumb that navigates still offers AT a way to activate it, and
1437    /// the current one offers none. Read off the **mounted** crumbs: `build`
1438    /// moves the action out, so a check that asked for it would see none.
1439    #[test]
1440    fn a_built_navigating_crumb_advertises_click_and_focus() {
1441        use teksilo_core::accesskit::Action;
1442
1443        let mut tree = themed_tree();
1444        let trail = tree.add(trail(2));
1445        tree.layout(SizeProposal::exact(300.0, 40.0));
1446        let _ = tree.render();
1447
1448        let crumbs = segment_ids(&tree, trail);
1449        assert_eq!(crumbs.len(), 2, "two crumbs, got {crumbs:?}");
1450        let navigating = tree.accessibility_node(crumbs[0]);
1451        assert!(
1452            navigating.actions().contains(&Action::Click),
1453            "a navigating crumb must advertise Click, or AT cannot follow it",
1454        );
1455        assert!(
1456            navigating.actions().contains(&Action::Focus),
1457            "a navigating crumb must advertise Focus",
1458        );
1459        let current = tree.accessibility_node(crumbs[1]);
1460        assert!(
1461            !current.actions().contains(&Action::Click),
1462            "the current crumb navigates nowhere and must not advertise Click",
1463        );
1464    }
1465
1466    /// A mouse is exact: the same press lands on the path bar, exactly as it
1467    /// did before the touch programme.
1468    #[test]
1469    fn a_mouse_just_outside_a_narrow_crumb_lands_on_the_bar() {
1470        let hits = Rc::new(std::cell::Cell::new(0_u32));
1471        let bar_taps = Rc::new(std::cell::Cell::new(0_u32));
1472        let (mut tree, crumb) = narrow_crumb_in_a_tappable_bar(hits.clone(), bar_taps.clone());
1473        let b = tree.bounds(crumb);
1474        let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
1475        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1476        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
1477        assert_eq!(
1478            hits.get(),
1479            0,
1480            "a mouse must not be given the crumb's outset"
1481        );
1482        assert_eq!(bar_taps.get(), 1);
1483    }
1484
1485    /// A finger tap navigates on the release, like every other tap target in
1486    /// the sweep.
1487    #[test]
1488    fn a_touch_tap_on_a_crumb_navigates_on_release() {
1489        use crate::button::press_test_support::{finger, touch};
1490        use teksilo_core::pointer::PointerPhase;
1491
1492        let hits = std::rc::Rc::new(std::cell::Cell::new(0_u32));
1493        let count = hits.clone();
1494        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1495        let bc = tree.add(
1496            Breadcrumb::new()
1497                .item(
1498                    BreadcrumbItem::new(lit!("A"))
1499                        .on_activate_fn(move |_| count.set(count.get() + 1)),
1500                )
1501                .item(BreadcrumbItem::new(lit!("Files"))),
1502        );
1503        tree.layout(SizeProposal::exact(300.0, 40.0));
1504        let _ = tree.render();
1505        let crumb = first_crumb(&tree, bc).expect("the trail has crumbs");
1506        let at = crumb.center();
1507        let id = finger();
1508        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1509        assert_eq!(hits.get(), 0, "the press navigates nowhere");
1510        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
1511        assert_eq!(hits.get(), 1, "the release does");
1512    }
1513
1514    fn first_crumb(
1515        tree: &WidgetTree,
1516        trail: teksilo_core::widget_id::WidgetId,
1517    ) -> Option<teksilo_canvas::Rect> {
1518        let mut best: Option<teksilo_canvas::Rect> = None;
1519        let mut stack = vec![trail];
1520        while let Some(id) = stack.pop() {
1521            if tree
1522                .widget_type_name(id)
1523                .is_some_and(|t| t.contains("BreadcrumbSegment"))
1524            {
1525                let b = tree.bounds(id);
1526                if best.is_none_or(|c| b.x < c.x) {
1527                    best = Some(b);
1528                }
1529            }
1530            stack.extend(tree.children(id).iter().copied());
1531        }
1532        best
1533    }
1534}