1use std::cell::RefCell;
35use std::rc::Rc;
36
37use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::accesskit::HasPopup;
40use teksilo_core::binding::BindingLevel;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::environment::LayoutDirection;
43use teksilo_core::event::{EventResponse, Key, WidgetEvent};
44use teksilo_core::overlay::OverlayPlacement;
45use teksilo_core::signal::Signal;
46use teksilo_core::widget::{
47 CursorIcon, EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
48};
49use teksilo_core::widget_builder::HandlerSet;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::{Color, CornerRadius};
52
53use crate::button::{Button, ButtonVariant};
54use crate::menu_item::MenuItem;
55use crate::menu_list::MenuList;
56use crate::popover_widget::PopoverButton;
57use crate::primitives::{HStack, IconWidget, Spacer};
58use teksilo_i18n::LocalizedString;
59
60const FALLBACK_CHAR_WIDTH: f32 = 8.0;
61const FALLBACK_LINE_HEIGHT: f32 = 16.0;
62
63type CommandFactory = Rc<dyn Fn(&mut EventContext)>;
66
67struct BreadcrumbEntry {
68 label: LocalizedString,
69 action: Option<CommandFactory>,
70 current: bool,
71 tooltip_text: Option<LocalizedString>,
72 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
73 composite_tooltip_content: Option<Box<dyn Widget>>,
74}
75
76impl std::fmt::Debug for BreadcrumbEntry {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("BreadcrumbEntry")
79 .field("label", &self.label)
80 .field("current", &self.current)
81 .finish()
82 }
83}
84
85pub const BREADCRUMB_ITEM_HEIGHT: f32 = 20.0;
87pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL: f32 = 6.0;
89pub const BREADCRUMB_SEPARATOR_GAP: f32 = 4.0;
91pub const BREADCRUMB_CORNER_RADIUS: f32 = 4.0;
93
94pub struct BreadcrumbItem {
96 label: LocalizedString,
97 action: Option<CommandFactory>,
98 current: bool,
99 tooltip_text: Option<LocalizedString>,
103 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
105 composite_tooltip_content: Option<Box<dyn Widget>>,
107}
108
109impl std::fmt::Debug for BreadcrumbItem {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.debug_struct("BreadcrumbItem")
112 .field("label", &self.label)
113 .field("current", &self.current)
114 .finish()
115 }
116}
117
118impl BreadcrumbItem {
119 pub fn new(label: impl Into<LocalizedString>) -> Self {
121 let ls: LocalizedString = label.into();
122 Self {
123 label: ls,
124 action: None,
125 current: false,
126 tooltip_text: None,
127 rich_tooltip_source: None,
128 composite_tooltip_content: None,
129 }
130 }
131
132 pub fn current(label: impl Into<LocalizedString>) -> Self {
136 let ls: LocalizedString = label.into();
137 Self {
138 label: ls,
139 action: None,
140 current: true,
141 tooltip_text: None,
142 rich_tooltip_source: None,
143 composite_tooltip_content: None,
144 }
145 }
146
147 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
149 self.action = Some(Rc::new(f));
150 self
151 }
152
153 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
156 self.tooltip_text = Some(text.into());
157 self.rich_tooltip_source = None;
158 self.composite_tooltip_content = None;
159 self
160 }
161
162 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
165 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
166 self.tooltip_text = None;
167 self.composite_tooltip_content = None;
168 self
169 }
170
171 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
174 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
175 self.tooltip_text = None;
176 self.composite_tooltip_content = None;
177 self
178 }
179
180 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
183 self.composite_tooltip_content = Some(Box::new(content));
184 self.tooltip_text = None;
185 self.rich_tooltip_source = None;
186 self
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum SegmentInteraction {
192 Idle,
193 Hovered,
194 Focused,
195}
196
197struct BreadcrumbSegment {
198 label: LocalizedString,
199 action: Option<CommandFactory>,
200 current: bool,
201 interaction: Signal<SegmentInteraction>,
202 tooltip_text: Option<LocalizedString>,
203 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
204 composite_tooltip_content: Option<Box<dyn Widget>>,
205}
206
207impl std::fmt::Debug for BreadcrumbSegment {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 f.debug_struct("BreadcrumbSegment")
210 .field("label", &self.label)
211 .field("current", &self.current)
212 .field("interaction", &self.interaction.get())
213 .finish()
214 }
215}
216
217impl BreadcrumbSegment {
218 fn new(label: LocalizedString, action: Option<CommandFactory>, current: bool) -> Self {
219 Self {
220 label,
221 action,
222 current,
223 interaction: Signal::new(SegmentInteraction::Idle),
224 tooltip_text: None,
225 rich_tooltip_source: None,
226 composite_tooltip_content: None,
227 }
228 }
229
230 fn with_tooltip(
234 mut self,
235 tooltip_text: Option<LocalizedString>,
236 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
237 composite_tooltip_content: Option<Box<dyn Widget>>,
238 ) -> Self {
239 self.tooltip_text = tooltip_text;
240 self.rich_tooltip_source = rich_tooltip_source;
241 self.composite_tooltip_content = composite_tooltip_content;
242 self
243 }
244
245 fn is_interactive(&self) -> bool {
246 !self.current && self.action.is_some()
247 }
248
249 fn estimate_width(&self, ctx: &LayoutContext) -> f32 {
250 let pad_h = BREADCRUMB_ITEM_PADDING_HORIZONTAL;
251 let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
252 let resolved = self.label.resolve_now();
253 let text_width = if let Some(backend) = ctx.text_backend {
254 backend
255 .borrow_mut()
256 .layout_single_line(&resolved, &ctx.theme.typography.small, None)
257 .width
258 } else {
259 resolved.len() as f32 * FALLBACK_CHAR_WIDTH
260 };
261 text_width + pad_h * 2.0 + envelope * 2.0
262 }
263}
264
265impl Widget for BreadcrumbSegment {
266 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
267 let self_id = ctx.self_id();
268 let interaction = ctx.signal(SegmentInteraction::Idle);
269 let registry = ctx.binding_registry();
270 interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
271 ctx.locale_signal()
274 .bind_to(self_id, registry, BindingLevel::Relayout);
275 self.interaction = interaction.clone();
276
277 let interactive = self.is_interactive();
278 let action = self.action.take();
279 let action_for_tap = action.clone();
280 let action_for_key = action.clone();
281 let action_for_access = action;
282
283 let handler_set = HandlerSet::new()
284 .on_tap({
285 let interaction = interaction.clone();
286 move |_pos, ctx: &mut EventContext| {
287 if !interactive {
288 return;
289 }
290 if let Some(ref action) = action_for_tap {
291 action(ctx);
292 }
293 interaction.set(SegmentInteraction::Hovered);
294 }
295 })
296 .on_hover({
297 let interaction = interaction.clone();
298 move |entered: bool, _ctx: &mut EventContext| {
299 if !interactive {
300 interaction.set(SegmentInteraction::Idle);
301 return;
302 }
303 if interaction.get() == SegmentInteraction::Focused {
304 return;
305 }
306 interaction.set(if entered {
307 SegmentInteraction::Hovered
308 } else {
309 SegmentInteraction::Idle
310 });
311 }
312 })
313 .on_focus({
314 let interaction = interaction.clone();
315 move |gained: bool, _ctx: &mut EventContext| {
316 if !interactive {
317 interaction.set(SegmentInteraction::Idle);
318 return;
319 }
320 interaction.set(if gained {
321 SegmentInteraction::Focused
322 } else {
323 SegmentInteraction::Idle
324 });
325 }
326 })
327 .on_key({
328 let interaction = interaction.clone();
329 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
330 if !interactive {
331 return EventResponse::Ignored;
332 }
333 match event {
334 WidgetEvent::KeyDown {
335 key: Key::Enter | Key::Space,
336 ..
337 } => {
338 if let Some(ref action) = action_for_key {
339 action(ctx);
340 }
341 interaction.set(SegmentInteraction::Focused);
342 EventResponse::Handled
343 }
344 _ => EventResponse::Ignored,
345 }
346 }
347 })
348 .on_access_action(move |action, ctx: &mut EventContext| {
349 if interactive && action == teksilo_core::accesskit::Action::Click {
350 if let Some(ref action) = action_for_access {
351 action(ctx);
352 }
353 EventResponse::Handled
354 } else {
355 EventResponse::Ignored
356 }
357 })
358 .focusable(interactive)
359 .cursor(if interactive {
360 CursorIcon::Pointer
361 } else {
362 CursorIcon::Default
363 });
364
365 ctx.apply_self_handlers(handler_set);
366
367 if let Some(content) = self.composite_tooltip_content.take() {
372 let delay = ctx.theme().motion.tooltip_delay_heavy;
373 crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
374 } else if let Some(source) = self.rich_tooltip_source.take() {
375 let delay = ctx.theme().motion.tooltip_delay;
376 crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
377 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
378 let delay = ctx.theme().motion.tooltip_delay;
379 crate::tooltip::attach_plain_tooltip(ctx, self_id, tooltip_text, delay);
380 }
381
382 Vec::new()
383 }
384
385 fn layout_response(
386 &self,
387 proposal: SizeProposal,
388 ctx: &LayoutContext,
389 ) -> teksilo_core::widget::LayoutResponse {
390 let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
391 let width = proposal.width.unwrap_or_else(|| self.estimate_width(ctx));
392 let text_height = if let Some(backend) = ctx.text_backend {
393 backend
394 .borrow_mut()
395 .layout_single_line(&self.label.resolve_now(), &ctx.theme.typography.small, None)
396 .height
397 } else {
398 FALLBACK_LINE_HEIGHT
399 };
400 let visual_h = text_height.max(BREADCRUMB_ITEM_HEIGHT);
401 Size::new(width, visual_h + envelope * 2.0).into()
402 }
403
404 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
405 let colors = &ctx.theme.colors;
406 let shape = &ctx.theme.shape;
407 let envelope = shape.focus_ring_offset + shape.focus_ring_width;
408 let interaction = self.interaction.get();
409 let interactive = self.is_interactive();
410
411 let visual = Rect::new(
413 bounds.x + envelope,
414 bounds.y + envelope,
415 (bounds.width - envelope * 2.0).max(0.0),
416 (bounds.height - envelope * 2.0).max(0.0),
417 );
418
419 if interactive {
420 let background = if interaction == SegmentInteraction::Hovered {
421 colors.accent.with_alpha(0.08)
422 } else if interaction == SegmentInteraction::Focused {
423 colors.accent.with_alpha(0.12)
424 } else {
425 Color::TRANSPARENT
426 };
427 if background.a() > 0.0 {
428 canvas.fill_rounded_rect(
429 visual,
430 CornerRadius::uniform(BREADCRUMB_CORNER_RADIUS),
431 background,
432 );
433 }
434 if interaction == SegmentInteraction::Focused {
436 let half_stroke = shape.focus_ring_width * 0.5;
437 let ring_rect = Rect::new(
438 bounds.x + half_stroke,
439 bounds.y + half_stroke,
440 (bounds.width - half_stroke * 2.0).max(0.0),
441 (bounds.height - half_stroke * 2.0).max(0.0),
442 );
443 let ring_radius = BREADCRUMB_CORNER_RADIUS + shape.focus_ring_offset + half_stroke;
444 canvas.stroke_rounded_rect(
445 ring_rect,
446 CornerRadius::uniform(ring_radius),
447 colors.focus_ring,
448 shape.focus_ring_width,
449 );
450 }
451 }
452
453 let text_color = if self.current {
454 colors.text_primary
455 } else if interactive && interaction == SegmentInteraction::Hovered {
456 colors.accent_hover
457 } else if interactive {
458 colors.accent
459 } else {
460 colors.text_secondary
461 };
462
463 let pad_h = BREADCRUMB_ITEM_PADDING_HORIZONTAL;
464 let text_bounds = Rect::new(
465 visual.x + pad_h,
466 visual.y,
467 (visual.width - pad_h * 2.0).max(0.0),
468 visual.height,
469 );
470 canvas.draw_text(
471 &self.label.resolve_now(),
472 text_bounds,
473 &ctx.theme.typography.small,
474 text_color,
475 );
476 }
477
478 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
479 builder.set_role(teksilo_core::accesskit::Role::Link);
486 builder.set_name(self.label.resolve_now());
487 if self.current {
488 builder.set_aria_current(teksilo_core::accesskit::AriaCurrent::Page);
489 } else if self.is_interactive() {
490 builder.add_action(teksilo_core::accesskit::Action::Click);
491 builder.add_action(teksilo_core::accesskit::Action::Focus);
492 }
493 }
494}
495
496#[derive(Debug)]
497struct BreadcrumbSeparator;
498
499impl Widget for BreadcrumbSeparator {
500 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
501 let self_id = ctx.self_id();
505 let registry = ctx.binding_registry();
506 ctx.locale_signal()
507 .bind_to(self_id, registry, BindingLevel::RepaintOnly);
508 Vec::new()
509 }
510
511 fn layout_response(
512 &self,
513 _proposal: SizeProposal,
514 ctx: &LayoutContext,
515 ) -> teksilo_core::widget::LayoutResponse {
516 let _ = ctx;
517 Size::new(BREADCRUMB_SEPARATOR_GAP * 3.0, BREADCRUMB_ITEM_HEIGHT).into()
518 }
519
520 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
521 let size = 10.0;
522 let icon_bounds = Rect::new(
523 bounds.x + (bounds.width - size) / 2.0,
524 bounds.y + (bounds.height - size) / 2.0,
525 size,
526 size,
527 );
528 let icon = if ctx.layout_direction == LayoutDirection::RightToLeft {
532 IconWidget::chevron_left(size)
533 } else {
534 IconWidget::chevron_right(size)
535 }
536 .color(teksilo_tokens::TextRole::Secondary);
537 icon.paint(icon_bounds, canvas, ctx);
538 }
539
540 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
541 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
546 builder.set_hidden();
547 }
548}
549
550enum BreadcrumbSlot {
551 Entry(Box<BreadcrumbEntry>),
552 Id(WidgetId),
553}
554
555struct CrumbMenuForm {
557 slot: usize,
558 label: LocalizedString,
559 action: Option<CommandFactory>,
560}
561
562pub struct Breadcrumb {
567 slots: Vec<BreadcrumbSlot>,
568 trailing_slot: Option<PendingChild>,
569 label: Option<LocalizedString>,
570
571 collapsed: Signal<Vec<bool>>,
575 is_overflowing: Signal<bool>,
577
578 unit_ids: Vec<WidgetId>,
582 collapsible: Vec<bool>,
585 menu_forms: Rc<Vec<CrumbMenuForm>>,
587 ellipsis_unit_id: Option<WidgetId>,
589 trailing_id: Option<WidgetId>,
590 root_child_id: Option<WidgetId>,
591 last_flags: RefCell<Vec<bool>>,
593}
594
595impl Breadcrumb {
596 pub fn new() -> Self {
599 Self {
600 slots: Vec::new(),
601 trailing_slot: None,
602 label: None,
603 collapsed: Signal::new(Vec::new()),
604 is_overflowing: Signal::new(false),
605 unit_ids: Vec::new(),
606 collapsible: Vec::new(),
607 menu_forms: Rc::new(Vec::new()),
608 ellipsis_unit_id: None,
609 trailing_id: None,
610 root_child_id: None,
611 last_flags: RefCell::new(Vec::new()),
612 }
613 }
614
615 pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
620 let ls: LocalizedString = text.into();
621 self.label = Some(ls);
622 self
623 }
624
625 pub fn item(mut self, item: BreadcrumbItem) -> Self {
629 self.slots
630 .push(BreadcrumbSlot::Entry(Box::new(BreadcrumbEntry {
631 label: item.label,
632 action: item.action,
633 current: item.current,
634 tooltip_text: item.tooltip_text,
635 rich_tooltip_source: item.rich_tooltip_source,
636 composite_tooltip_content: item.composite_tooltip_content,
637 })));
638 self
639 }
640
641 pub fn item_id(mut self, id: WidgetId) -> Self {
647 self.slots.push(BreadcrumbSlot::Id(id));
648 self
649 }
650
651 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
656 self.trailing_slot = Some(PendingChild::Deferred(Box::new(widget)));
657 self
658 }
659
660 pub fn trailing_slot_id(mut self, id: WidgetId) -> Self {
663 self.trailing_slot = Some(PendingChild::Id(id));
664 self
665 }
666
667 pub fn is_overflowing(&self) -> Signal<bool> {
670 self.is_overflowing.clone()
671 }
672}
673
674impl Default for Breadcrumb {
675 fn default() -> Self {
676 Self::new()
677 }
678}
679
680impl std::fmt::Debug for Breadcrumb {
681 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682 f.debug_struct("Breadcrumb")
683 .field("item_count", &self.slots.len())
684 .finish()
685 }
686}
687
688impl Widget for Breadcrumb {
689 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
690 let slots = std::mem::take(&mut self.slots);
691 let n = slots.len();
692
693 let mut unit_ids: Vec<WidgetId> = Vec::with_capacity(n);
694 let mut collapsible: Vec<bool> = Vec::with_capacity(n);
695 let mut menu_forms: Vec<CrumbMenuForm> = Vec::new();
696
697 for (i, slot) in slots.into_iter().enumerate() {
698 let is_first = i == 0;
699 let is_last = i + 1 == n;
700
701 let (seg_id, form): (WidgetId, Option<(LocalizedString, Option<CommandFactory>)>) =
703 match slot {
704 BreadcrumbSlot::Entry(entry) => {
705 let action = entry.action;
706 let seg = BreadcrumbSegment::new(
707 entry.label.clone(),
708 action.clone(),
709 entry.current,
710 )
711 .with_tooltip(
712 entry.tooltip_text,
713 entry.rich_tooltip_source,
714 entry.composite_tooltip_content,
715 );
716 (ctx.add(seg), Some((entry.label, action)))
717 }
718 BreadcrumbSlot::Id(id) => (id, None),
719 };
720
721 let can_collapse = form.is_some() && !is_first && !is_last;
724 collapsible.push(can_collapse);
725
726 let unit_id = if is_first {
730 seg_id
731 } else {
732 let sep_id = ctx.add(BreadcrumbSeparator);
733 ctx.add(
734 HStack::new()
735 .spacing(0.0)
736 .add_child(sep_id)
737 .add_child(seg_id),
738 )
739 };
740 unit_ids.push(unit_id);
741
742 if can_collapse {
743 let collapsed = self.collapsed.clone();
744 ctx.visible_when(
745 unit_id,
746 collapsed.map(move |flags| flags.get(i).copied() != Some(true)),
747 );
748 if let Some((label, action)) = form {
749 menu_forms.push(CrumbMenuForm {
750 slot: i,
751 label,
752 action,
753 });
754 }
755 }
756 }
757
758 self.collapsible = collapsible;
759 self.menu_forms = Rc::new(menu_forms);
760 self.collapsed.set(vec![false; n]);
761 *self.last_flags.borrow_mut() = vec![false; n];
762
763 let has_collapsible = self.collapsible.iter().any(|&c| c);
769 self.ellipsis_unit_id = if has_collapsible {
770 let menu_forms = self.menu_forms.clone();
771 let mut menu = MenuList::new();
772 for form in menu_forms.iter() {
773 let slot = form.slot;
774 let action = form.action.clone();
775 let mut row = MenuItem::new(form.label.clone()).enabled(action.is_some());
776 if let Some(act) = action {
777 row = row.on_activate_fn(move |ctx| {
778 act(ctx);
779 ctx.dismiss_self_overlay_chain();
780 });
781 }
782 let collapsed = self.collapsed.clone();
783 let visible = collapsed.map(move |flags| flags.get(slot).copied() == Some(true));
784 menu = menu.item_when(row, visible);
785 }
786
787 let trigger = Button::new(teksilo_i18n::lit!("…"))
788 .variant(ButtonVariant::Ghost)
789 .tooltip(teksilo_i18n::tr_widget!(breadcrumb_overflow()));
790 let chevron = PopoverButton::new(trigger)
791 .content(menu)
792 .bare()
794 .placement(OverlayPlacement::BelowPreferred)
795 .has_popup_kind(HasPopup::Menu);
796 let chevron_id = ctx.add(chevron);
797
798 let sep_id = ctx.add(BreadcrumbSeparator);
799 let unit_id = ctx.add(
800 HStack::new()
801 .spacing(0.0)
802 .add_child(sep_id)
803 .add_child(chevron_id),
804 );
805 ctx.visible_when(unit_id, self.is_overflowing.clone());
806 Some(unit_id)
807 } else {
808 None
809 };
810
811 let mut row = HStack::new().spacing(0.0);
814 for (i, &uid) in unit_ids.iter().enumerate() {
815 row = row.add_child(uid);
816 if i == 0 {
817 if let Some(eu) = self.ellipsis_unit_id {
818 row = row.add_child(eu);
819 }
820 }
821 }
822 self.unit_ids = unit_ids;
823
824 if let Some(trailing) = self.trailing_slot.take() {
825 let trailing_id = match trailing {
826 PendingChild::Id(id) => id,
827 PendingChild::Deferred(w) => ctx.add_boxed(w),
828 };
829 self.trailing_id = Some(trailing_id);
830 row = row.child(Spacer::new()).add_child(trailing_id);
831 }
832
833 let root_id = ctx.add(row);
834 self.root_child_id = Some(root_id);
835 vec![root_id]
836 }
837
838 fn layout_response(
839 &self,
840 proposal: SizeProposal,
841 ctx: &LayoutContext,
842 ) -> teksilo_core::widget::LayoutResponse {
843 let Some(root) = self.root_child_id else {
844 return proposal.resolve(0.0, 0.0).into();
845 };
846
847 let probe = SizeProposal::unspecified();
852 let mut natural_w = 0.0_f32;
853 for &uid in &self.unit_ids {
854 if let Some(s) = ctx.measure_intrinsic(uid, probe) {
855 natural_w += s.width;
856 }
857 }
858 let has_trailing = self.trailing_id.is_some();
859 if let Some(tid) = self.trailing_id
860 && let Some(s) = ctx.measure_intrinsic(tid, probe)
861 {
862 natural_w += s.width;
863 }
864
865 let width = if has_trailing {
870 proposal.width.unwrap_or(natural_w)
871 } else {
872 proposal
873 .width
874 .map(|w| natural_w.min(w))
875 .unwrap_or(natural_w)
876 };
877
878 let height = ctx
879 .child_size(root, proposal)
880 .map(|s| s.height)
881 .unwrap_or(BREADCRUMB_ITEM_HEIGHT);
882 Size::new(width, height).into()
883 }
884
885 fn place_children(
886 &self,
887 bounds: Rect,
888 _proposal: SizeProposal,
889 children: &mut [WidgetPlacement],
890 ctx: &LayoutContext,
891 ) {
892 for child in children.iter_mut() {
893 child.origin = bounds.origin();
894 child.size = bounds.size();
895 }
896
897 let probe = SizeProposal::unspecified();
900 let trailing_w = self
901 .trailing_id
902 .and_then(|tid| ctx.measure_intrinsic(tid, probe))
903 .map(|s| s.width)
904 .unwrap_or(0.0);
905 let avail = (bounds.width - trailing_w).max(0.0);
906
907 let unit_w: Vec<f32> = self
908 .unit_ids
909 .iter()
910 .map(|&uid| {
911 ctx.measure_intrinsic(uid, probe)
912 .map(|s| s.width)
913 .unwrap_or(0.0)
914 })
915 .collect();
916 let ellipsis_w = self
917 .ellipsis_unit_id
918 .and_then(|eu| ctx.measure_intrinsic(eu, probe))
919 .map(|s| s.width)
920 .unwrap_or(0.0);
921
922 let flags = compute_breadcrumb_overflow(avail, &unit_w, &self.collapsible, ellipsis_w);
923
924 if *self.last_flags.borrow() != flags {
925 *self.last_flags.borrow_mut() = flags.clone();
926 let any = flags.iter().any(|&c| c);
927 self.collapsed.set(flags);
928 if self.is_overflowing.get() != any {
929 self.is_overflowing.set(any);
930 }
931 }
932 }
933
934 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
935 builder.set_role(teksilo_core::accesskit::Role::Navigation);
936 if let Some(ref label) = self.label {
937 builder.set_name(label.clone());
938 }
939 }
940
941 fn children(&self) -> Vec<WidgetId> {
942 self.root_child_id.into_iter().collect()
943 }
944}
945
946fn compute_breadcrumb_overflow(
955 avail: f32,
956 unit_w: &[f32],
957 collapsible: &[bool],
958 ellipsis_w: f32,
959) -> Vec<bool> {
960 let n = unit_w.len();
961 let mut collapsed = vec![false; n];
962 if n == 0 {
963 return collapsed;
964 }
965 let full: f32 = unit_w.iter().sum();
966 if full <= avail + 0.5 {
967 return collapsed; }
969 loop {
970 let any_hidden = collapsed.iter().any(|&c| c);
971 let shown: f32 = (0..n)
972 .filter(|&i| !collapsed[i])
973 .map(|i| unit_w[i])
974 .sum::<f32>()
975 + if any_hidden { ellipsis_w } else { 0.0 };
976 if shown <= avail + 0.5 {
977 break;
978 }
979 match (0..n).find(|&i| collapsible[i] && !collapsed[i]) {
980 Some(i) => collapsed[i] = true,
981 None => break, }
983 }
984 collapsed
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990 use teksilo_canvas::MockTextBackend;
991 use teksilo_core::widget_tree::WidgetTree;
992 use teksilo_i18n::lit;
993
994 fn themed_tree() -> WidgetTree {
995 WidgetTree::new()
996 .with_theme(teksilo_core::presets::intui::light())
997 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
998 }
999
1000 fn trail(n: usize) -> Breadcrumb {
1001 let mut bc = Breadcrumb::new();
1002 for i in 0..n {
1003 let last = i + 1 == n;
1004 let item = if last {
1005 BreadcrumbItem::current(lit!(format!("Crumb {i}")))
1006 } else {
1007 BreadcrumbItem::new(lit!(format!("Crumb {i}"))).on_activate_fn(|_| {})
1008 };
1009 bc = bc.item(item);
1010 }
1011 bc
1012 }
1013
1014 #[test]
1017 fn breadcrumb_tooltip_appears_after_the_hover_delay() {
1018 let mut tree = themed_tree();
1024 let bc = Breadcrumb::new()
1025 .item(BreadcrumbItem::new(lit!("Home")).on_activate_fn(|_| {}))
1026 .item(BreadcrumbItem::current(lit!("Here")).tooltip(lit!("Where you are")));
1027 tree.add(bc);
1028 tree.layout(SizeProposal::exact(400.0, 60.0));
1029
1030 let segment = tree
1031 .find_by_label("Here")
1032 .expect("the current crumb renders");
1033 tree.pointer_move(tree.bounds(segment).center());
1034 assert!(
1035 tree.active_overlays().is_empty(),
1036 "must wait for the hover delay"
1037 );
1038
1039 let delay = tree.theme().motion.tooltip_delay;
1040 tree.advance_time(delay + std::time::Duration::from_millis(50));
1041 assert_eq!(
1042 tree.active_overlays().len(),
1043 1,
1044 "a breadcrumb crumb's tooltip must actually open"
1045 );
1046 assert!(tree.find_by_label("Where you are").is_some());
1047 }
1048
1049 #[test]
1050 fn a_crumb_contributes_a_tooltip_entry_only_when_it_has_one() {
1051 let mut bare = themed_tree();
1055 bare.add(trail(3));
1056 bare.layout(SizeProposal::exact(400.0, 60.0));
1057 let baseline = bare.tooltip_entry_count();
1058
1059 let mut tipped = themed_tree();
1060 let bc = Breadcrumb::new()
1061 .item(BreadcrumbItem::new(lit!("Crumb 0")).on_activate_fn(|_| {}))
1062 .item(BreadcrumbItem::new(lit!("Crumb 1")).on_activate_fn(|_| {}))
1063 .item(BreadcrumbItem::current(lit!("Crumb 2")).tooltip(lit!("Tip")));
1064 tipped.add(bc);
1065 tipped.layout(SizeProposal::exact(400.0, 60.0));
1066
1067 assert_eq!(
1068 tipped.tooltip_entry_count(),
1069 baseline + 1,
1070 "exactly the one crumb carrying a tooltip adds an entry"
1071 );
1072 }
1073
1074 #[test]
1077 fn nothing_collapses_when_it_all_fits() {
1078 let flags = compute_breadcrumb_overflow(
1079 500.0,
1080 &[40.0, 40.0, 40.0, 40.0, 40.0],
1081 &[false, true, true, true, false],
1082 30.0,
1083 );
1084 assert_eq!(flags, vec![false; 5]);
1085 }
1086
1087 #[test]
1088 fn middle_collapses_from_the_left_keeping_root_and_current() {
1089 let flags = compute_breadcrumb_overflow(
1091 160.0,
1092 &[40.0, 40.0, 40.0, 40.0, 40.0],
1093 &[false, true, true, true, false],
1094 30.0,
1095 );
1096 assert_eq!(flags, vec![false, true, true, false, false]);
1097 }
1098
1099 #[test]
1100 fn all_middle_collapses_when_very_narrow() {
1101 let flags = compute_breadcrumb_overflow(
1102 100.0,
1103 &[40.0, 40.0, 40.0, 40.0, 40.0],
1104 &[false, true, true, true, false],
1105 30.0,
1106 );
1107 assert_eq!(
1108 flags,
1109 vec![false, true, true, true, false],
1110 "root and current always survive; all middle collapse"
1111 );
1112 }
1113
1114 #[test]
1115 fn two_crumbs_never_collapse() {
1116 let flags = compute_breadcrumb_overflow(10.0, &[40.0, 40.0], &[false, false], 30.0);
1117 assert_eq!(flags, vec![false, false], "no collapsible middle to hide");
1118 }
1119
1120 #[test]
1123 fn wide_trail_does_not_overflow_narrow_does() {
1124 let bc = trail(6);
1125 let overflowing = bc.is_overflowing();
1126 let mut tree = themed_tree();
1127 let _id = tree.add(bc);
1128
1129 tree.layout(SizeProposal::exact(2000.0, 30.0));
1130 assert!(!overflowing.get(), "a wide trail should not overflow");
1131
1132 tree.layout(SizeProposal::exact(160.0, 30.0));
1133 assert!(
1134 overflowing.get(),
1135 "a narrow trail should collapse middle crumbs into the … menu"
1136 );
1137
1138 tree.layout(SizeProposal::exact(2000.0, 30.0));
1139 assert!(
1140 !overflowing.get(),
1141 "re-widening restores all crumbs (intrinsic measure → no stale collapse)"
1142 );
1143 }
1144
1145 #[test]
1146 fn overflow_menu_rows_are_dormant_while_the_chevron_is_closed() {
1147 let bc = trail(6);
1150 let mut tree = themed_tree();
1151 let _id = tree.add(bc);
1152 tree.layout(SizeProposal::exact(160.0, 30.0)); let active_menu_items: u32 = tree
1155 .widget_type_histogram()
1156 .iter()
1157 .filter(|(k, _)| k.contains("menu_item::MenuItem"))
1158 .map(|(_, v)| *v)
1159 .sum();
1160 assert_eq!(
1161 active_menu_items, 0,
1162 "overflow menu rows stay dormant until the … chevron opens"
1163 );
1164 }
1165
1166 #[test]
1167 fn builds_under_rtl() {
1168 use teksilo_core::environment::LayoutDirection;
1169 let bc = trail(5);
1170 let mut tree = themed_tree();
1171 tree.set_layout_direction(LayoutDirection::RightToLeft);
1172 let id = tree.add(bc);
1173 tree.layout(SizeProposal::exact(200.0, 30.0));
1174 assert!(tree.bounds(id).width > 0.0);
1175 }
1176}