1use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
30use ratatui::{
31 buffer::Buffer,
32 layout::Rect,
33 style::{Color, Modifier, Style},
34 text::{Line, Span},
35 widgets::{Paragraph, Widget},
36};
37
38use crate::traits::ClickRegion;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum BreadcrumbAction {
43 Navigate(String),
45 ExpandEllipsis,
47}
48
49#[derive(Debug, Clone)]
51pub struct BreadcrumbItem {
52 pub id: String,
54 pub label: String,
56 pub icon: Option<String>,
58 pub enabled: bool,
60}
61
62impl BreadcrumbItem {
63 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
70 Self {
71 id: id.into(),
72 label: label.into(),
73 icon: None,
74 enabled: true,
75 }
76 }
77
78 pub fn icon(mut self, icon: impl Into<String>) -> Self {
80 self.icon = Some(icon.into());
81 self
82 }
83
84 pub fn enabled(mut self, enabled: bool) -> Self {
86 self.enabled = enabled;
87 self
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct BreadcrumbState {
94 pub items: Vec<BreadcrumbItem>,
96 pub selected_index: Option<usize>,
98 pub focused: bool,
100 pub enabled: bool,
102 pub expanded: bool,
104}
105
106impl Default for BreadcrumbState {
107 fn default() -> Self {
108 Self {
109 items: Vec::new(),
110 selected_index: None,
111 focused: false,
112 enabled: true,
113 expanded: false,
114 }
115 }
116}
117
118impl BreadcrumbState {
119 pub fn new(items: Vec<BreadcrumbItem>) -> Self {
121 Self {
122 items,
123 ..Default::default()
124 }
125 }
126
127 pub fn empty() -> Self {
129 Self::default()
130 }
131
132 pub fn select_next(&mut self) {
134 if self.items.is_empty() {
135 return;
136 }
137 self.selected_index = Some(match self.selected_index {
138 Some(idx) if idx + 1 < self.items.len() => idx + 1,
139 Some(idx) => idx, None => 0,
141 });
142 }
143
144 pub fn select_prev(&mut self) {
146 if self.items.is_empty() {
147 return;
148 }
149 self.selected_index = Some(match self.selected_index {
150 Some(idx) if idx > 0 => idx - 1,
151 Some(idx) => idx, None => self.items.len().saturating_sub(1),
153 });
154 }
155
156 pub fn select(&mut self, index: usize) {
158 if index < self.items.len() {
159 self.selected_index = Some(index);
160 }
161 }
162
163 pub fn select_by_id(&mut self, id: &str) {
165 if let Some(idx) = self.items.iter().position(|item| item.id == id) {
166 self.selected_index = Some(idx);
167 }
168 }
169
170 pub fn select_first(&mut self) {
172 if !self.items.is_empty() {
173 self.selected_index = Some(0);
174 }
175 }
176
177 pub fn select_last(&mut self) {
179 if !self.items.is_empty() {
180 self.selected_index = Some(self.items.len() - 1);
181 }
182 }
183
184 pub fn clear_selection(&mut self) {
186 self.selected_index = None;
187 }
188
189 pub fn push(&mut self, item: BreadcrumbItem) {
191 self.items.push(item);
192 }
193
194 pub fn pop(&mut self) -> Option<BreadcrumbItem> {
196 let item = self.items.pop();
197 if let Some(idx) = self.selected_index {
199 if idx >= self.items.len() && !self.items.is_empty() {
200 self.selected_index = Some(self.items.len() - 1);
201 } else if self.items.is_empty() {
202 self.selected_index = None;
203 }
204 }
205 item
206 }
207
208 pub fn clear(&mut self) {
210 self.items.clear();
211 self.selected_index = None;
212 self.expanded = false;
213 }
214
215 pub fn set_items(&mut self, items: Vec<BreadcrumbItem>) {
217 self.items = items;
218 if let Some(idx) = self.selected_index {
220 if idx >= self.items.len() {
221 self.selected_index = if self.items.is_empty() {
222 None
223 } else {
224 Some(self.items.len() - 1)
225 };
226 }
227 }
228 self.expanded = false;
229 }
230
231 pub fn toggle_expanded(&mut self) {
233 self.expanded = !self.expanded;
234 }
235
236 pub fn selected_item(&self) -> Option<&BreadcrumbItem> {
238 self.selected_index.and_then(|idx| self.items.get(idx))
239 }
240
241 pub fn len(&self) -> usize {
243 self.items.len()
244 }
245
246 pub fn is_empty(&self) -> bool {
248 self.items.is_empty()
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct BreadcrumbStyle {
255 pub separator: &'static str,
257 pub separator_style: Style,
259
260 pub ellipsis: &'static str,
262 pub ellipsis_style: Style,
264 pub collapse_threshold: usize,
266 pub visible_start: usize,
268 pub visible_end: usize,
270
271 pub item_style: Style,
273 pub focused_item_style: Style,
275 pub selected_item_style: Style,
277 pub hovered_item_style: Style,
279 pub disabled_item_style: Style,
281 pub last_item_style: Style,
283
284 pub icon_style: Style,
286 pub icon_separator: &'static str,
288
289 pub padding: (u16, u16),
291}
292
293impl Default for BreadcrumbStyle {
294 fn default() -> Self {
295 Self {
296 separator: " > ",
297 separator_style: Style::default().fg(Color::DarkGray),
298
299 ellipsis: "...",
300 ellipsis_style: Style::default()
301 .fg(Color::Cyan)
302 .add_modifier(Modifier::BOLD),
303 collapse_threshold: 4,
304 visible_start: 1,
305 visible_end: 2,
306
307 item_style: Style::default().fg(Color::Blue),
308 focused_item_style: Style::default()
309 .fg(Color::Yellow)
310 .add_modifier(Modifier::BOLD),
311 selected_item_style: Style::default().fg(Color::Black).bg(Color::Yellow),
312 hovered_item_style: Style::default()
313 .fg(Color::Cyan)
314 .add_modifier(Modifier::UNDERLINED),
315 disabled_item_style: Style::default().fg(Color::DarkGray),
316 last_item_style: Style::default()
317 .fg(Color::White)
318 .add_modifier(Modifier::BOLD),
319
320 icon_style: Style::default(),
321 icon_separator: " ",
322
323 padding: (1, 1),
324 }
325 }
326}
327
328impl From<&crate::theme::Theme> for BreadcrumbStyle {
329 fn from(theme: &crate::theme::Theme) -> Self {
330 let p = &theme.palette;
331 Self {
332 separator: " > ",
333 separator_style: Style::default().fg(p.text_disabled),
334
335 ellipsis: "...",
336 ellipsis_style: Style::default()
337 .fg(p.secondary)
338 .add_modifier(Modifier::BOLD),
339 collapse_threshold: 4,
340 visible_start: 1,
341 visible_end: 2,
342
343 item_style: Style::default().fg(Color::Blue),
344 focused_item_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
345 selected_item_style: Style::default().fg(p.highlight_fg).bg(p.highlight_bg),
346 hovered_item_style: Style::default()
347 .fg(p.secondary)
348 .add_modifier(Modifier::UNDERLINED),
349 disabled_item_style: Style::default().fg(p.text_disabled),
350 last_item_style: Style::default().fg(p.text).add_modifier(Modifier::BOLD),
351
352 icon_style: Style::default(),
353 icon_separator: " ",
354
355 padding: (1, 1),
356 }
357 }
358}
359
360impl BreadcrumbStyle {
361 pub fn slash() -> Self {
363 Self {
364 separator: " / ",
365 ..Default::default()
366 }
367 }
368
369 pub fn chevron() -> Self {
371 Self {
372 separator: " › ",
373 separator_style: Style::default().fg(Color::Gray),
374 ..Default::default()
375 }
376 }
377
378 pub fn arrow() -> Self {
380 Self {
381 separator: " → ",
382 separator_style: Style::default().fg(Color::Gray),
383 ..Default::default()
384 }
385 }
386
387 pub fn minimal() -> Self {
389 Self {
390 separator: " / ",
391 separator_style: Style::default().fg(Color::DarkGray),
392 item_style: Style::default().fg(Color::Gray),
393 focused_item_style: Style::default().fg(Color::White),
394 selected_item_style: Style::default()
395 .fg(Color::White)
396 .add_modifier(Modifier::BOLD),
397 last_item_style: Style::default().fg(Color::White),
398 ellipsis_style: Style::default().fg(Color::Gray),
399 ..Default::default()
400 }
401 }
402
403 pub fn separator(mut self, sep: &'static str) -> Self {
405 self.separator = sep;
406 self
407 }
408
409 pub fn separator_style(mut self, style: Style) -> Self {
411 self.separator_style = style;
412 self
413 }
414
415 pub fn collapse_threshold(mut self, threshold: usize) -> Self {
417 self.collapse_threshold = threshold;
418 self
419 }
420
421 pub fn visible_ends(mut self, start: usize, end: usize) -> Self {
423 self.visible_start = start;
424 self.visible_end = end;
425 self
426 }
427
428 pub fn item_style(mut self, style: Style) -> Self {
430 self.item_style = style;
431 self
432 }
433
434 pub fn focused_item_style(mut self, style: Style) -> Self {
436 self.focused_item_style = style;
437 self
438 }
439
440 pub fn last_item_style(mut self, style: Style) -> Self {
442 self.last_item_style = style;
443 self
444 }
445
446 pub fn padding(mut self, left: u16, right: u16) -> Self {
448 self.padding = (left, right);
449 self
450 }
451}
452
453#[derive(Debug, Clone)]
455enum VisibleElement {
456 Item(usize),
458 Ellipsis,
460}
461
462pub struct Breadcrumb<'a> {
467 state: &'a BreadcrumbState,
468 style: BreadcrumbStyle,
469 hovered_index: Option<usize>,
471}
472
473impl<'a> Breadcrumb<'a> {
474 pub fn new(state: &'a BreadcrumbState) -> Self {
476 Self {
477 state,
478 style: BreadcrumbStyle::default(),
479 hovered_index: None,
480 }
481 }
482
483 pub fn style(mut self, style: BreadcrumbStyle) -> Self {
485 self.style = style;
486 self
487 }
488
489 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
491 self.style(BreadcrumbStyle::from(theme))
492 }
493
494 pub fn hovered(mut self, index: Option<usize>) -> Self {
496 self.hovered_index = index;
497 self
498 }
499
500 fn visible_elements(&self) -> Vec<VisibleElement> {
502 let len = self.state.items.len();
503
504 if len <= self.style.collapse_threshold || self.state.expanded {
506 return (0..len).map(VisibleElement::Item).collect();
507 }
508
509 let mut elements = Vec::new();
510
511 for i in 0..self.style.visible_start.min(len) {
513 elements.push(VisibleElement::Item(i));
514 }
515
516 elements.push(VisibleElement::Ellipsis);
518
519 let start = len.saturating_sub(self.style.visible_end);
521 for i in start..len {
522 elements.push(VisibleElement::Item(i));
523 }
524
525 elements
526 }
527
528 fn item_style(&self, idx: usize) -> Style {
530 let item = &self.state.items[idx];
531 let is_last = idx == self.state.items.len() - 1;
532 let is_selected = self.state.selected_index == Some(idx);
533 let is_hovered = self.hovered_index == Some(idx);
534 let is_focused = self.state.focused && is_selected;
535
536 if !item.enabled {
537 self.style.disabled_item_style
538 } else if is_focused {
539 self.style.selected_item_style
540 } else if is_hovered {
541 self.style.hovered_item_style
542 } else if is_selected {
543 self.style.focused_item_style
544 } else if is_last {
545 self.style.last_item_style
546 } else {
547 self.style.item_style
548 }
549 }
550
551 pub fn render_stateful(
553 self,
554 area: Rect,
555 buf: &mut Buffer,
556 ) -> Vec<ClickRegion<BreadcrumbAction>> {
557 let mut regions = Vec::new();
558
559 if self.state.items.is_empty() {
560 return regions;
561 }
562
563 let visible = self.visible_elements();
564 let mut spans = Vec::new();
565 let mut x_offset = area.x + self.style.padding.0;
566
567 let mut element_positions: Vec<(VisibleElement, u16, u16)> = Vec::new();
569
570 for (i, element) in visible.iter().enumerate() {
571 if i > 0 {
573 let sep_span = Span::styled(self.style.separator, self.style.separator_style);
574 let sep_width = self.style.separator.chars().count() as u16;
575 spans.push(sep_span);
576 x_offset += sep_width;
577 }
578
579 match element {
580 VisibleElement::Item(idx) => {
581 let item = &self.state.items[*idx];
582 let style = self.item_style(*idx);
583
584 let mut item_text = String::new();
586 if let Some(ref icon) = item.icon {
587 item_text.push_str(icon);
588 item_text.push_str(self.style.icon_separator);
589 }
590 item_text.push_str(&item.label);
591
592 let item_width = item_text.chars().count() as u16;
593 element_positions.push((element.clone(), x_offset, item_width));
594
595 spans.push(Span::styled(item_text, style));
596 x_offset += item_width;
597 }
598 VisibleElement::Ellipsis => {
599 let ellipsis_width = self.style.ellipsis.chars().count() as u16;
600 element_positions.push((element.clone(), x_offset, ellipsis_width));
601
602 spans.push(Span::styled(self.style.ellipsis, self.style.ellipsis_style));
603 x_offset += ellipsis_width;
604 }
605 }
606 }
607
608 let line = Line::from(spans);
610 let paragraph = Paragraph::new(line);
611 paragraph.render(area, buf);
612
613 for (element, start_x, width) in element_positions {
615 if width == 0 {
616 continue;
617 }
618
619 let click_area = Rect::new(start_x, area.y, width, 1);
620
621 match element {
622 VisibleElement::Item(idx) => {
623 let item = &self.state.items[idx];
624 if item.enabled {
625 regions.push(ClickRegion::new(
626 click_area,
627 BreadcrumbAction::Navigate(item.id.clone()),
628 ));
629 }
630 }
631 VisibleElement::Ellipsis => {
632 regions.push(ClickRegion::new(
633 click_area,
634 BreadcrumbAction::ExpandEllipsis,
635 ));
636 }
637 }
638 }
639
640 regions
641 }
642
643 pub fn calculate_width(&self) -> u16 {
645 if self.state.items.is_empty() {
646 return 0;
647 }
648
649 let visible = self.visible_elements();
650 let mut width = self.style.padding.0 + self.style.padding.1;
651
652 for (i, element) in visible.iter().enumerate() {
653 if i > 0 {
655 width += self.style.separator.chars().count() as u16;
656 }
657
658 match element {
659 VisibleElement::Item(idx) => {
660 let item = &self.state.items[*idx];
661 if let Some(ref icon) = item.icon {
662 width += icon.chars().count() as u16;
663 width += self.style.icon_separator.chars().count() as u16;
664 }
665 width += item.label.chars().count() as u16;
666 }
667 VisibleElement::Ellipsis => {
668 width += self.style.ellipsis.chars().count() as u16;
669 }
670 }
671 }
672
673 width
674 }
675}
676
677pub fn handle_breadcrumb_key(
690 key: &KeyEvent,
691 state: &mut BreadcrumbState,
692) -> Option<BreadcrumbAction> {
693 if !state.enabled || state.items.is_empty() {
694 return None;
695 }
696
697 match key.code {
698 KeyCode::Left | KeyCode::Char('h') => {
699 state.select_prev();
700 None
701 }
702 KeyCode::Right | KeyCode::Char('l') => {
703 state.select_next();
704 None
705 }
706 KeyCode::Home => {
707 state.select_first();
708 None
709 }
710 KeyCode::End => {
711 state.select_last();
712 None
713 }
714 KeyCode::Enter | KeyCode::Char(' ') => {
715 if let Some(item) = state.selected_item() {
716 if item.enabled {
717 Some(BreadcrumbAction::Navigate(item.id.clone()))
718 } else {
719 None
720 }
721 } else {
722 None
723 }
724 }
725 KeyCode::Char('e') => {
726 state.toggle_expanded();
727 Some(BreadcrumbAction::ExpandEllipsis)
728 }
729 _ => None,
730 }
731}
732
733pub fn handle_breadcrumb_mouse(
743 mouse: &MouseEvent,
744 state: &mut BreadcrumbState,
745 regions: &[ClickRegion<BreadcrumbAction>],
746) -> Option<BreadcrumbAction> {
747 if !state.enabled {
748 return None;
749 }
750
751 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
752 let col = mouse.column;
753 let row = mouse.row;
754
755 for region in regions {
756 if region.contains(col, row) {
757 match ®ion.data {
758 BreadcrumbAction::Navigate(id) => {
759 state.select_by_id(id);
761 return Some(region.data.clone());
762 }
763 BreadcrumbAction::ExpandEllipsis => {
764 state.toggle_expanded();
765 return Some(BreadcrumbAction::ExpandEllipsis);
766 }
767 }
768 }
769 }
770 }
771
772 None
773}
774
775pub fn get_hovered_index(
779 col: u16,
780 row: u16,
781 regions: &[ClickRegion<BreadcrumbAction>],
782 state: &BreadcrumbState,
783) -> Option<usize> {
784 for region in regions {
785 if region.contains(col, row) {
786 if let BreadcrumbAction::Navigate(ref id) = region.data {
787 return state.items.iter().position(|item| &item.id == id);
788 }
789 }
790 }
791 None
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn test_breadcrumb_item_creation() {
800 let item = BreadcrumbItem::new("home", "Home").icon("🏠").enabled(true);
801
802 assert_eq!(item.id, "home");
803 assert_eq!(item.label, "Home");
804 assert_eq!(item.icon, Some("🏠".to_string()));
805 assert!(item.enabled);
806 }
807
808 #[test]
809 fn test_breadcrumb_state_navigation() {
810 let items = vec![
811 BreadcrumbItem::new("a", "A"),
812 BreadcrumbItem::new("b", "B"),
813 BreadcrumbItem::new("c", "C"),
814 ];
815 let mut state = BreadcrumbState::new(items);
816
817 assert!(state.selected_index.is_none());
818
819 state.select_next();
820 assert_eq!(state.selected_index, Some(0));
821
822 state.select_next();
823 assert_eq!(state.selected_index, Some(1));
824
825 state.select_prev();
826 assert_eq!(state.selected_index, Some(0));
827
828 state.select_prev();
829 assert_eq!(state.selected_index, Some(0)); state.select_last();
832 assert_eq!(state.selected_index, Some(2));
833
834 state.select_first();
835 assert_eq!(state.selected_index, Some(0));
836 }
837
838 #[test]
839 fn test_breadcrumb_state_select_by_id() {
840 let items = vec![
841 BreadcrumbItem::new("home", "Home"),
842 BreadcrumbItem::new("settings", "Settings"),
843 BreadcrumbItem::new("profile", "Profile"),
844 ];
845 let mut state = BreadcrumbState::new(items);
846
847 state.select_by_id("settings");
848 assert_eq!(state.selected_index, Some(1));
849
850 state.select_by_id("nonexistent");
851 assert_eq!(state.selected_index, Some(1)); }
853
854 #[test]
855 fn test_breadcrumb_state_push_pop() {
856 let mut state = BreadcrumbState::empty();
857 assert!(state.is_empty());
858
859 state.push(BreadcrumbItem::new("a", "A"));
860 state.push(BreadcrumbItem::new("b", "B"));
861 assert_eq!(state.len(), 2);
862
863 state.select_last();
864 assert_eq!(state.selected_index, Some(1));
865
866 let popped = state.pop();
867 assert!(popped.is_some());
868 assert_eq!(popped.unwrap().id, "b");
869 assert_eq!(state.selected_index, Some(0)); }
871
872 #[test]
873 fn test_breadcrumb_state_clear() {
874 let items = vec![BreadcrumbItem::new("a", "A"), BreadcrumbItem::new("b", "B")];
875 let mut state = BreadcrumbState::new(items);
876 state.select(1);
877
878 state.clear();
879 assert!(state.is_empty());
880 assert!(state.selected_index.is_none());
881 }
882
883 #[test]
884 fn test_breadcrumb_style_presets() {
885 let default = BreadcrumbStyle::default();
886 assert_eq!(default.separator, " > ");
887
888 let slash = BreadcrumbStyle::slash();
889 assert_eq!(slash.separator, " / ");
890
891 let chevron = BreadcrumbStyle::chevron();
892 assert_eq!(chevron.separator, " › ");
893
894 let arrow = BreadcrumbStyle::arrow();
895 assert_eq!(arrow.separator, " → ");
896 }
897
898 #[test]
899 fn test_breadcrumb_style_builder() {
900 let style = BreadcrumbStyle::default()
901 .separator(" | ")
902 .collapse_threshold(5)
903 .visible_ends(2, 3)
904 .padding(2, 2);
905
906 assert_eq!(style.separator, " | ");
907 assert_eq!(style.collapse_threshold, 5);
908 assert_eq!(style.visible_start, 2);
909 assert_eq!(style.visible_end, 3);
910 assert_eq!(style.padding, (2, 2));
911 }
912
913 #[test]
914 fn test_breadcrumb_collapse_logic() {
915 let items: Vec<BreadcrumbItem> = (0..6)
917 .map(|i| BreadcrumbItem::new(format!("item{}", i), format!("Item {}", i)))
918 .collect();
919 let state = BreadcrumbState::new(items);
920 let breadcrumb = Breadcrumb::new(&state);
921
922 let visible = breadcrumb.visible_elements();
925 assert_eq!(visible.len(), 4); let mut expanded_state = state.clone();
929 expanded_state.expanded = true;
930 let expanded_breadcrumb = Breadcrumb::new(&expanded_state);
931 let visible = expanded_breadcrumb.visible_elements();
932 assert_eq!(visible.len(), 6);
933 }
934
935 #[test]
936 fn test_breadcrumb_no_collapse() {
937 let items: Vec<BreadcrumbItem> = (0..3)
939 .map(|i| BreadcrumbItem::new(format!("item{}", i), format!("Item {}", i)))
940 .collect();
941 let state = BreadcrumbState::new(items);
942 let breadcrumb = Breadcrumb::new(&state);
943
944 let visible = breadcrumb.visible_elements();
945 assert_eq!(visible.len(), 3); }
947
948 #[test]
949 fn test_handle_breadcrumb_key() {
950 let items = vec![
951 BreadcrumbItem::new("a", "A"),
952 BreadcrumbItem::new("b", "B"),
953 BreadcrumbItem::new("c", "C"),
954 ];
955 let mut state = BreadcrumbState::new(items);
956 state.focused = true;
957
958 let key = KeyEvent::from(KeyCode::Right);
960 handle_breadcrumb_key(&key, &mut state);
961 assert_eq!(state.selected_index, Some(0));
962
963 handle_breadcrumb_key(&key, &mut state);
965 assert_eq!(state.selected_index, Some(1));
966
967 state.select(1);
969 let key = KeyEvent::from(KeyCode::Enter);
970 let action = handle_breadcrumb_key(&key, &mut state);
971 assert_eq!(action, Some(BreadcrumbAction::Navigate("b".to_string())));
972 }
973
974 #[test]
975 fn test_handle_breadcrumb_key_disabled() {
976 let items = vec![BreadcrumbItem::new("a", "A")];
977 let mut state = BreadcrumbState::new(items);
978 state.enabled = false;
979
980 let key = KeyEvent::from(KeyCode::Right);
981 let action = handle_breadcrumb_key(&key, &mut state);
982 assert!(action.is_none());
983 }
984
985 #[test]
986 fn test_calculate_width() {
987 let items = vec![
988 BreadcrumbItem::new("home", "Home"),
989 BreadcrumbItem::new("settings", "Settings"),
990 ];
991 let state = BreadcrumbState::new(items);
992 let breadcrumb = Breadcrumb::new(&state);
993
994 let width = breadcrumb.calculate_width();
995 assert_eq!(width, 17);
997 }
998
999 #[test]
1000 fn test_click_region_contains() {
1001 let region = ClickRegion::new(
1002 Rect::new(10, 5, 20, 1),
1003 BreadcrumbAction::Navigate("test".to_string()),
1004 );
1005
1006 assert!(region.contains(10, 5));
1007 assert!(region.contains(29, 5));
1008 assert!(!region.contains(9, 5));
1009 assert!(!region.contains(30, 5));
1010 }
1011}