Skip to main content

iced_cube/navigation/
breadcrumb.rs

1//! Shows where the current view sits in a hierarchy, with a link to each
2//! level above it.
3//!
4//! The last crumb is the current view and never responds to clicks. Crumbs
5//! before it are links when they have a message. A long trail collapses its
6//! middle into an ellipsis with [`max_items`](Breadcrumb::max_items); see
7//! [`slots`]. A trail too wide for one line hides more of its middle until
8//! it fits, unless [`collapse_to_fit`](Breadcrumb::collapse_to_fit) is off.
9//!
10//! Keyboard shortcuts resolve through a [`Keymap`] of [`Action`]s; see
11//! [`default_keymap`] and [`crate::keys`].
12
13use iced::keyboard::key::Named;
14use iced::widget::{self, button::Status, container, row, text};
15use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme};
16
17use crate::fit::first_fit;
18use crate::icon::{Glyph, themed};
19use crate::keys::{self, Chord, Keymap};
20use crate::lucide;
21use crate::theme::{Tokens, mix, radius, text_size};
22
23const ICON_SIZE: f32 = 14.0;
24
25/// What goes between two crumbs.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
27pub enum Separator {
28    #[default]
29    Chevron,
30    Slash,
31}
32
33impl Separator {
34    pub const ALL: [Separator; 2] = [Separator::Chevron, Separator::Slash];
35}
36
37/// One place in a rendered trail.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum Slot {
40    /// The crumb at this index.
41    Crumb(usize),
42    /// Crumbs left out of the middle.
43    Ellipsis,
44}
45
46/// The crumbs to show for a trail of `len`, keeping at most `max` of them.
47///
48/// The first crumb and the last `max - 1` stay, with an ellipsis for the
49/// rest, so the root and the levels nearest the current view remain one
50/// click away. `max` is at least 2. `None` shows every crumb.
51pub fn slots(len: usize, max: Option<usize>) -> Vec<Slot> {
52    let max = max.map_or(len, |max| max.max(2));
53    if len <= max {
54        return (0..len).map(Slot::Crumb).collect();
55    }
56    let tail = max - 1;
57    let mut slots = vec![Slot::Crumb(0), Slot::Ellipsis];
58    slots.extend((len - tail..len).map(Slot::Crumb));
59    slots
60}
61
62/// One level in a [`Breadcrumb`].
63#[derive(Debug, Clone)]
64pub struct Crumb<'a, Message> {
65    label: text::Fragment<'a>,
66    icon: Option<Glyph>,
67    on_press: Option<Message>,
68}
69
70/// Creates a crumb with a text label. Add a message with
71/// [`on_press`](Crumb::on_press) to make it a link.
72pub fn crumb<'a, Message>(label: impl text::IntoFragment<'a>) -> Crumb<'a, Message> {
73    Crumb {
74        label: label.into_fragment(),
75        icon: None,
76        on_press: None,
77    }
78}
79
80impl<Message> Crumb<'_, Message> {
81    /// Adds an icon before the label, such as a house for the root.
82    pub fn icon(mut self, glyph: Glyph) -> Self {
83        self.icon = Some(glyph);
84        self
85    }
86
87    pub fn on_press(mut self, message: Message) -> Self {
88        self.on_press = Some(message);
89        self
90    }
91
92    pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
93        self.on_press = message;
94        self
95    }
96
97    pub fn is_link(&self) -> bool {
98        self.on_press.is_some()
99    }
100}
101
102/// A breadcrumb trail builder. Convert it into an [`Element`] to render.
103#[derive(Debug)]
104pub struct Breadcrumb<'a, Message> {
105    crumbs: Vec<Crumb<'a, Message>>,
106    separator: Separator,
107    max_items: Option<usize>,
108    on_expand: Option<Message>,
109    collapse_to_fit: bool,
110}
111
112/// Creates a trail from the root to the current view, in that order.
113pub fn breadcrumb<'a, Message>(
114    crumbs: impl IntoIterator<Item = Crumb<'a, Message>>,
115) -> Breadcrumb<'a, Message> {
116    Breadcrumb {
117        crumbs: crumbs.into_iter().collect(),
118        separator: Separator::default(),
119        max_items: None,
120        on_expand: None,
121        collapse_to_fit: true,
122    }
123}
124
125/// The crumb limits to try, longest first, when the trail must fit on one
126/// line: the requested limit (or every crumb), then one fewer at a time
127/// down to the first crumb and the current one.
128pub fn fit_limits(len: usize, max: Option<usize>) -> Vec<usize> {
129    let start = max.map_or(len, |max| max.max(2)).min(len);
130    if start <= 2 {
131        return vec![start];
132    }
133    (2..=start).rev().collect()
134}
135
136impl<Message> Breadcrumb<'_, Message> {
137    pub fn separator(mut self, separator: Separator) -> Self {
138        self.separator = separator;
139        self
140    }
141
142    /// Shows at most `max` crumbs, collapsing the middle into an ellipsis.
143    pub fn max_items(mut self, max: usize) -> Self {
144        self.max_items = Some(max);
145        self
146    }
147
148    /// Makes the ellipsis a button that sends `message`, such as one that
149    /// lifts the limit to show the whole trail.
150    pub fn on_expand(mut self, message: Message) -> Self {
151        self.on_expand = Some(message);
152        self
153    }
154
155    /// Whether a trail too wide for one line collapses more of its middle
156    /// until it fits, before it wraps. Defaults to `true`. Turn it off to
157    /// show every crumb on as many lines as it takes, such as after the
158    /// reader expands the trail.
159    pub fn collapse_to_fit(mut self, collapse: bool) -> Self {
160        self.collapse_to_fit = collapse;
161        self
162    }
163
164    pub fn len(&self) -> usize {
165        self.crumbs.len()
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.crumbs.is_empty()
170    }
171}
172
173impl<'a, Message: Clone + 'a> From<Breadcrumb<'a, Message>> for Element<'a, Message> {
174    fn from(breadcrumb: Breadcrumb<'a, Message>) -> Self {
175        let Breadcrumb {
176            crumbs,
177            separator,
178            max_items,
179            on_expand,
180            collapse_to_fit,
181        } = breadcrumb;
182        let len = crumbs.len();
183        if !collapse_to_fit {
184            return trail(&crumbs, separator, max_items, &on_expand)
185                .wrap()
186                .into();
187        }
188
189        // Each shorter trail is tried in turn; the shortest wraps if even
190        // it is too wide.
191        let limits = fit_limits(len, max_items);
192        let last = limits.len().saturating_sub(1);
193        let candidates = limits
194            .into_iter()
195            .enumerate()
196            .map(|(index, max)| {
197                let trail = trail(&crumbs, separator, Some(max), &on_expand);
198                if index == last {
199                    trail.wrap().into()
200                } else {
201                    trail.into()
202                }
203            })
204            .collect();
205        first_fit(candidates)
206    }
207}
208
209/// One row of crumbs, showing at most `max` of them.
210fn trail<'a, Message: Clone + 'a>(
211    crumbs: &[Crumb<'a, Message>],
212    separator: Separator,
213    max: Option<usize>,
214    on_expand: &Option<Message>,
215) -> iced::widget::Row<'a, Message> {
216    let len = crumbs.len();
217    let mut trail = row![].spacing(6).align_y(Alignment::Center);
218    let mut first = true;
219    for slot in slots(len, max) {
220        let element = match slot {
221            Slot::Crumb(at) => {
222                let Some(crumb) = crumbs.get(at) else {
223                    continue;
224                };
225                item(crumb.clone(), at + 1 == len)
226            }
227            Slot::Ellipsis => ellipsis(on_expand.clone()),
228        };
229        // A divider travels with the crumb after it, so a wrapped trail
230        // never ends a line on a separator.
231        if first {
232            trail = trail.push(element);
233        } else {
234            trail = trail.push(
235                row![divider(separator), element]
236                    .spacing(6)
237                    .align_y(Alignment::Center),
238            );
239        }
240        first = false;
241    }
242    trail
243}
244
245fn item<'a, Message: Clone + 'a>(crumb: Crumb<'a, Message>, current: bool) -> Element<'a, Message> {
246    let Crumb {
247        label,
248        icon,
249        on_press,
250    } = crumb;
251    let message = on_press.filter(|_| !current);
252
253    let link = message.is_some();
254    let content = |status: Status| {
255        let mut content = row![].spacing(6).align_y(Alignment::Center);
256        if let Some(glyph) = icon {
257            content = content.push(themed(glyph, ICON_SIZE, 1.0, move |theme| {
258                foreground(&Tokens::of(theme), current, link, status)
259            }));
260        }
261        content.push(
262            text(label.clone())
263                .size(text_size::SM)
264                .wrapping(text::Wrapping::None),
265        )
266    };
267
268    let Some(message) = message else {
269        return container(content(Status::Active))
270            .padding(Padding::from([2.0, 0.0]))
271            .style(move |theme: &Theme| container::Style {
272                text_color: Some(foreground(
273                    &Tokens::of(theme),
274                    current,
275                    false,
276                    Status::Active,
277                )),
278                ..container::Style::default()
279            })
280            .into();
281    };
282    // The icon is drawn twice so it follows the label's hover colour: iced
283    // tints an svg by its own hover state, not its button's.
284    widget::button(widget::hover(
285        content(Status::Active),
286        content(Status::Hovered),
287    ))
288    .padding(Padding::from([2.0, 0.0]))
289    .on_press(message)
290    .style(|theme, status| style(&Tokens::of(theme), status))
291    .into()
292}
293
294fn divider<'a, Message: 'a>(separator: Separator) -> Element<'a, Message> {
295    let colour = |theme: &Theme| Tokens::of(theme).muted_foreground;
296    match separator {
297        Separator::Chevron => themed(lucide!(ChevronRight), ICON_SIZE, 1.0, colour).into(),
298        Separator::Slash => text("/")
299            .size(text_size::SM)
300            .style(move |theme: &Theme| text::Style {
301                color: Some(colour(theme)),
302            })
303            .into(),
304    }
305}
306
307fn ellipsis<'a, Message: Clone + 'a>(on_expand: Option<Message>) -> Element<'a, Message> {
308    let glyph = |status| {
309        themed(lucide!(Ellipsis), 16.0, 1.0, move |theme| {
310            foreground(&Tokens::of(theme), false, true, status)
311        })
312    };
313    let Some(message) = on_expand else {
314        return container(glyph(Status::Active))
315            .center_y(Length::Fixed(24.0))
316            .into();
317    };
318    widget::button(glyph(Status::Active))
319        .padding(Padding::from([4.0, 2.0]))
320        .on_press(message)
321        .style(|theme, status| ellipsis_style(&Tokens::of(theme), status))
322        .into()
323}
324
325/// The text colour of a crumb: the current one in the foreground colour,
326/// the levels above it muted until a link is hovered.
327pub fn foreground(tokens: &Tokens, current: bool, link: bool, status: Status) -> Color {
328    let hover = matches!(status, Status::Hovered | Status::Pressed);
329    if current || (link && hover) {
330        tokens.foreground
331    } else {
332        tokens.muted_foreground
333    }
334}
335
336/// The style of a crumb that is a link.
337pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
338    widget::button::Style {
339        background: None,
340        text_color: foreground(tokens, false, true, status),
341        border: Border::default(),
342        shadow: Shadow::default(),
343        snap: true,
344    }
345}
346
347/// The style of the ellipsis when it expands the trail: a small ghost
348/// button, so it reads as clickable.
349pub fn ellipsis_style(tokens: &Tokens, status: Status) -> widget::button::Style {
350    let hover = matches!(status, Status::Hovered | Status::Pressed);
351    widget::button::Style {
352        background: hover.then(|| Background::Color(mix(tokens.background, tokens.muted, 0.9))),
353        text_color: foreground(tokens, false, true, status),
354        border: Border {
355            radius: radius::SM.into(),
356            ..Border::default()
357        },
358        shadow: Shadow::default(),
359        snap: true,
360    }
361}
362
363/// What a breadcrumb keyboard shortcut does.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
365pub enum Action {
366    Parent,
367    Root,
368}
369
370impl Action {
371    /// The index of the crumb to go to in a trail of `len`, where the last
372    /// crumb is the current view, or `None` when already at the top.
373    pub fn target(self, len: usize) -> Option<usize> {
374        match self {
375            Action::Parent => len.checked_sub(2),
376            Action::Root => (len > 1).then_some(0),
377        }
378    }
379}
380
381impl keys::Action for Action {
382    const ALL: &'static [Self] = &[Action::Parent, Action::Root];
383
384    fn defaults() -> Keymap<Self> {
385        default_keymap()
386    }
387
388    fn name(self) -> &'static str {
389        match self {
390            Action::Parent => "Parent",
391            Action::Root => "Root",
392        }
393    }
394
395    fn description(self) -> &'static str {
396        match self {
397            Action::Parent => "Goes up one level, to the crumb before the current one.",
398            Action::Root => "Goes to the first crumb.",
399        }
400    }
401}
402
403/// The default breadcrumb shortcuts:
404///
405/// | Keys | Action |
406/// | --- | --- |
407/// | `Alt+ArrowUp` | [`Action::Parent`] |
408/// | `Alt+Shift+ArrowUp` | [`Action::Root`] |
409///
410/// `Alt+ArrowUp` goes to the parent folder in desktop file managers.
411pub fn default_keymap() -> Keymap<Action> {
412    Keymap::new()
413        .bind(Chord::named(Named::ArrowUp).alt(), Action::Parent)
414        .bind(Chord::named(Named::ArrowUp).alt().shift(), Action::Root)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::theme::{dark, light};
421
422    #[test]
423    fn every_crumb_shows_without_a_limit() {
424        assert_eq!(slots(0, None), vec![]);
425        assert_eq!(slots(3, None), (0..3).map(Slot::Crumb).collect::<Vec<_>>());
426        assert_eq!(slots(3, Some(3)), slots(3, None));
427        assert_eq!(slots(3, Some(10)), slots(3, None));
428    }
429
430    #[test]
431    fn fit_limits_shorten_one_crumb_at_a_time() {
432        assert_eq!(fit_limits(5, None), vec![5, 4, 3, 2]);
433        assert_eq!(fit_limits(5, Some(3)), vec![3, 2]);
434        assert_eq!(fit_limits(5, Some(9)), vec![5, 4, 3, 2]);
435        assert_eq!(fit_limits(2, None), vec![2]);
436        assert_eq!(fit_limits(1, None), vec![1]);
437        assert_eq!(fit_limits(0, None), vec![0]);
438    }
439
440    #[test]
441    fn collapsing_to_fit_is_on_by_default() {
442        let trail = breadcrumb::<u8>([crumb("Home")]);
443        assert!(trail.collapse_to_fit);
444        assert!(!trail.collapse_to_fit(false).collapse_to_fit);
445    }
446
447    #[test]
448    fn a_long_trail_keeps_the_root_and_the_nearest_levels() {
449        assert_eq!(
450            slots(6, Some(3)),
451            vec![
452                Slot::Crumb(0),
453                Slot::Ellipsis,
454                Slot::Crumb(4),
455                Slot::Crumb(5)
456            ]
457        );
458        assert_eq!(
459            slots(6, Some(4)),
460            vec![
461                Slot::Crumb(0),
462                Slot::Ellipsis,
463                Slot::Crumb(3),
464                Slot::Crumb(4),
465                Slot::Crumb(5)
466            ]
467        );
468    }
469
470    #[test]
471    fn the_limit_never_drops_below_root_and_current() {
472        let expected = vec![Slot::Crumb(0), Slot::Ellipsis, Slot::Crumb(4)];
473        assert_eq!(slots(5, Some(0)), expected);
474        assert_eq!(slots(5, Some(1)), expected);
475        assert_eq!(slots(5, Some(2)), expected);
476    }
477
478    #[test]
479    fn builder_defaults_and_options() {
480        let trail: Breadcrumb<'_, u8> = breadcrumb([crumb("Home").on_press(0), crumb("Docs")]);
481        assert_eq!(trail.separator, Separator::Chevron);
482        assert_eq!(trail.max_items, None);
483        assert!(trail.on_expand.is_none());
484        assert_eq!(trail.len(), 2);
485        assert!(!trail.is_empty());
486
487        let trail = trail.separator(Separator::Slash).max_items(3).on_expand(9);
488        assert_eq!(trail.separator, Separator::Slash);
489        assert_eq!(trail.max_items, Some(3));
490        assert_eq!(trail.on_expand, Some(9));
491        assert!(breadcrumb::<u8>([]).is_empty());
492    }
493
494    #[test]
495    fn crumbs_are_links_only_with_a_message() {
496        let home = crumb::<u8>("Home").icon(lucide!(House));
497        assert!(!home.is_link());
498        assert_eq!(home.icon.map(Glyph::name), Some("house"));
499        assert!(home.clone().on_press(1).is_link());
500        assert!(!home.on_press(1).on_press_maybe(None).is_link());
501    }
502
503    #[test]
504    fn current_crumb_is_foreground_and_links_are_muted_until_hovered() {
505        for theme in [light(), dark()] {
506            let tokens = Tokens::of(&theme);
507            for status in [Status::Active, Status::Hovered] {
508                assert_eq!(foreground(&tokens, true, false, status), tokens.foreground);
509                assert_eq!(
510                    foreground(&tokens, false, false, status),
511                    tokens.muted_foreground
512                );
513            }
514            assert_eq!(
515                foreground(&tokens, false, true, Status::Active),
516                tokens.muted_foreground
517            );
518            assert_eq!(
519                foreground(&tokens, false, true, Status::Hovered),
520                tokens.foreground
521            );
522            assert_eq!(
523                foreground(&tokens, false, true, Status::Pressed),
524                tokens.foreground
525            );
526        }
527    }
528
529    #[test]
530    fn links_have_no_background_or_border() {
531        let tokens = Tokens::of(&light());
532        for status in [Status::Active, Status::Hovered, Status::Pressed] {
533            let style = style(&tokens, status);
534            assert!(style.background.is_none());
535            assert_eq!(style.border.width, 0.0);
536        }
537    }
538
539    #[test]
540    fn the_ellipsis_fills_only_on_hover() {
541        for theme in [light(), dark()] {
542            let tokens = Tokens::of(&theme);
543            assert!(ellipsis_style(&tokens, Status::Active).background.is_none());
544            assert!(
545                ellipsis_style(&tokens, Status::Hovered)
546                    .background
547                    .is_some()
548            );
549        }
550    }
551
552    #[test]
553    fn actions_target_the_parent_and_the_root() {
554        assert_eq!(Action::Parent.target(4), Some(2));
555        assert_eq!(Action::Root.target(4), Some(0));
556        assert_eq!(Action::Parent.target(2), Some(0));
557        assert_eq!(Action::Parent.target(1), None);
558        assert_eq!(Action::Root.target(1), None);
559        assert_eq!(Action::Parent.target(0), None);
560        assert_eq!(Action::Root.target(0), None);
561    }
562
563    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
564        let chord: Chord = chord.parse().unwrap();
565        keymap.resolve(chord.key(), chord.modifiers())
566    }
567
568    #[test]
569    fn default_keymap_covers_every_action() {
570        let keymap = default_keymap();
571        assert_eq!(press(&keymap, "Alt+ArrowUp"), Some(Action::Parent));
572        assert_eq!(press(&keymap, "Alt+Shift+ArrowUp"), Some(Action::Root));
573        assert_eq!(press(&keymap, "ArrowUp"), None);
574        for action in <Action as keys::Action>::ALL {
575            assert!(!keymap.chords(action).is_empty(), "{action:?}");
576        }
577        assert_eq!(Keymap::<Action>::defaults(), keymap);
578    }
579
580    #[test]
581    fn keymap_overrides_and_unbinding() {
582        let keymap = default_keymap()
583            .unbind(&Chord::named(Named::ArrowUp).alt())
584            .bind("Backspace".parse().unwrap(), Action::Parent);
585        assert_eq!(press(&keymap, "Alt+ArrowUp"), None);
586        assert_eq!(press(&keymap, "Backspace"), Some(Action::Parent));
587        assert_eq!(press(&keymap, "Alt+Shift+ArrowUp"), Some(Action::Root));
588    }
589}