1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum Slot {
40 Crumb(usize),
42 Ellipsis,
44}
45
46pub 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#[derive(Debug, Clone)]
64pub struct Crumb<'a, Message> {
65 label: text::Fragment<'a>,
66 icon: Option<Glyph>,
67 on_press: Option<Message>,
68}
69
70pub 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 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#[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
112pub 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
125pub 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 pub fn max_items(mut self, max: usize) -> Self {
144 self.max_items = Some(max);
145 self
146 }
147
148 pub fn on_expand(mut self, message: Message) -> Self {
151 self.on_expand = Some(message);
152 self
153 }
154
155 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 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
209fn 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 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 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
325pub 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
336pub 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
347pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
365pub enum Action {
366 Parent,
367 Root,
368}
369
370impl Action {
371 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
403pub 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}