1use 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
74type 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
96pub const BREADCRUMB_ITEM_HEIGHT: f32 = 20.0;
98pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL: f32 = 6.0;
100
101pub fn breadcrumb_item_padding_horizontal(tokens: &InputTokens) -> f32 {
104 spacing(BREADCRUMB_ITEM_PADDING_HORIZONTAL, tokens)
105}
106pub const BREADCRUMB_SEPARATOR_GAP: f32 = 4.0;
108
109pub fn breadcrumb_separator_gap(tokens: &InputTokens) -> f32 {
112 spacing(BREADCRUMB_SEPARATOR_GAP, tokens)
113}
114pub const BREADCRUMB_CORNER_RADIUS: f32 = 4.0;
116
117pub struct BreadcrumbItem {
119 label: LocalizedString,
120 action: Option<CommandFactory>,
121 current: bool,
122 tooltip_text: Option<LocalizedString>,
126 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
128 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 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 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 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 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 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 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 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 painted_size: std::cell::Cell<Size>,
229 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 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 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 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 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 let interactive = self.navigates.get();
470
471 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 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 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 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 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 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 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
608struct CrumbMenuForm {
610 slot: usize,
611 label: LocalizedString,
612 action: Option<CommandFactory>,
613}
614
615pub struct Breadcrumb {
620 slots: Vec<BreadcrumbSlot>,
621 trailing_slot: Option<PendingChild>,
622 label: Option<LocalizedString>,
623
624 collapsed: Signal<Vec<bool>>,
628 is_overflowing: Signal<bool>,
630
631 unit_ids: Vec<WidgetId>,
635 collapsible: Vec<bool>,
638 menu_forms: Rc<Vec<CrumbMenuForm>>,
640 ellipsis_unit_id: Option<WidgetId>,
642 trailing_id: Option<WidgetId>,
643 root_child_id: Option<WidgetId>,
644 last_flags: RefCell<Vec<bool>>,
646}
647
648impl Breadcrumb {
649 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 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 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 pub fn items(self, items: impl IntoIterator<Item = BreadcrumbItem>) -> Self {
699 items.into_iter().fold(self, Self::item)
700 }
701
702 pub fn item_id(mut self, id: WidgetId) -> Self {
708 self.slots.push(BreadcrumbSlot::Id(id));
709 self
710 }
711
712 pub fn item_ids(self, ids: impl IntoIterator<Item = WidgetId>) -> Self {
717 ids.into_iter().fold(self, Self::item_id)
718 }
719
720 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 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 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 let can_collapse = form.is_some() && !is_first && !is_last;
786 collapsible.push(can_collapse);
787
788 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 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 .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 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 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 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 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
998fn 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; }
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, }
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 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 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 #[test]
1120 fn breadcrumb_tooltip_appears_after_the_hover_delay() {
1121 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 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 #[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 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 #[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 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)); 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 #[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 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 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 fn first_segment_id(tree: &WidgetTree, trail: WidgetId) -> Option<WidgetId> {
1367 segment_ids(tree, trail).into_iter().next()
1368 }
1369
1370 #[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 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 #[test]
1402 fn only_the_navigating_crumb_declares_an_outset() {
1403 use teksilo_tokens::PointerKind;
1404
1405 let mut tree = themed_tree();
1406 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 #[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 #[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 #[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}