Skip to main content

ratatui_interact/components/
breadcrumb.rs

1//! Breadcrumb component - Hierarchical navigation path display
2//!
3//! A breadcrumb component that displays hierarchical navigation paths with
4//! support for ellipsis collapsing, keyboard/mouse interaction, and customizable styling.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{Breadcrumb, BreadcrumbItem, BreadcrumbState, BreadcrumbStyle};
10//! use ratatui::layout::Rect;
11//!
12//! // Create breadcrumb items
13//! let items = vec![
14//!     BreadcrumbItem::new("home", "Home"),
15//!     BreadcrumbItem::new("settings", "Settings"),
16//!     BreadcrumbItem::new("profile", "Profile"),
17//! ];
18//!
19//! // Create state
20//! let mut state = BreadcrumbState::new(items);
21//!
22//! // Create breadcrumb widget with chevron style
23//! let breadcrumb = Breadcrumb::new(&state)
24//!     .style(BreadcrumbStyle::chevron());
25//!
26//! // Render and handle events (see handle_breadcrumb_key, handle_breadcrumb_mouse)
27//! ```
28
29use 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/// Actions a breadcrumb component can emit.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum BreadcrumbAction {
43    /// Navigate to an item by ID.
44    Navigate(String),
45    /// Expand collapsed items (show all).
46    ExpandEllipsis,
47}
48
49/// A single item in the breadcrumb path.
50#[derive(Debug, Clone)]
51pub struct BreadcrumbItem {
52    /// Unique identifier for click actions.
53    pub id: String,
54    /// Display text.
55    pub label: String,
56    /// Optional icon prefix (emoji or symbol).
57    pub icon: Option<String>,
58    /// Can this item be clicked?
59    pub enabled: bool,
60}
61
62impl BreadcrumbItem {
63    /// Create a new breadcrumb item.
64    ///
65    /// # Arguments
66    ///
67    /// * `id` - Unique identifier for this item
68    /// * `label` - Display text
69    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    /// Set an icon for this item.
79    pub fn icon(mut self, icon: impl Into<String>) -> Self {
80        self.icon = Some(icon.into());
81        self
82    }
83
84    /// Set whether this item is enabled (clickable).
85    pub fn enabled(mut self, enabled: bool) -> Self {
86        self.enabled = enabled;
87        self
88    }
89}
90
91/// State for a breadcrumb component.
92#[derive(Debug, Clone)]
93pub struct BreadcrumbState {
94    /// All breadcrumb items.
95    pub items: Vec<BreadcrumbItem>,
96    /// Currently selected/highlighted item (for keyboard navigation).
97    pub selected_index: Option<usize>,
98    /// Whether the component has keyboard focus.
99    pub focused: bool,
100    /// Whether navigation is enabled.
101    pub enabled: bool,
102    /// Whether ellipsis is expanded (showing all items).
103    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    /// Create a new breadcrumb state with the given items.
120    pub fn new(items: Vec<BreadcrumbItem>) -> Self {
121        Self {
122            items,
123            ..Default::default()
124        }
125    }
126
127    /// Create an empty breadcrumb state.
128    pub fn empty() -> Self {
129        Self::default()
130    }
131
132    /// Select the next item (move right).
133    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, // Stay at end
140            None => 0,
141        });
142    }
143
144    /// Select the previous item (move left).
145    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, // Stay at start
152            None => self.items.len().saturating_sub(1),
153        });
154    }
155
156    /// Select an item by index.
157    pub fn select(&mut self, index: usize) {
158        if index < self.items.len() {
159            self.selected_index = Some(index);
160        }
161    }
162
163    /// Select an item by ID.
164    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    /// Select the first item.
171    pub fn select_first(&mut self) {
172        if !self.items.is_empty() {
173            self.selected_index = Some(0);
174        }
175    }
176
177    /// Select the last item.
178    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    /// Clear the selection.
185    pub fn clear_selection(&mut self) {
186        self.selected_index = None;
187    }
188
189    /// Push a new item to the end of the path.
190    pub fn push(&mut self, item: BreadcrumbItem) {
191        self.items.push(item);
192    }
193
194    /// Pop the last item from the path.
195    pub fn pop(&mut self) -> Option<BreadcrumbItem> {
196        let item = self.items.pop();
197        // Adjust selection if it was pointing to the removed item
198        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    /// Clear all items.
209    pub fn clear(&mut self) {
210        self.items.clear();
211        self.selected_index = None;
212        self.expanded = false;
213    }
214
215    /// Set new items, replacing existing ones.
216    pub fn set_items(&mut self, items: Vec<BreadcrumbItem>) {
217        self.items = items;
218        // Reset selection if it's now out of bounds
219        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    /// Toggle expanded state (show/hide collapsed items).
232    pub fn toggle_expanded(&mut self) {
233        self.expanded = !self.expanded;
234    }
235
236    /// Get the currently selected item.
237    pub fn selected_item(&self) -> Option<&BreadcrumbItem> {
238        self.selected_index.and_then(|idx| self.items.get(idx))
239    }
240
241    /// Get the number of items.
242    pub fn len(&self) -> usize {
243        self.items.len()
244    }
245
246    /// Check if empty.
247    pub fn is_empty(&self) -> bool {
248        self.items.is_empty()
249    }
250}
251
252/// Style configuration for breadcrumb component.
253#[derive(Debug, Clone)]
254pub struct BreadcrumbStyle {
255    /// Separator between items (e.g., " > ", " / ", " › ").
256    pub separator: &'static str,
257    /// Style for the separator.
258    pub separator_style: Style,
259
260    /// Ellipsis string when items are collapsed.
261    pub ellipsis: &'static str,
262    /// Style for the ellipsis.
263    pub ellipsis_style: Style,
264    /// Number of items before collapsing occurs.
265    pub collapse_threshold: usize,
266    /// Number of items to show at start when collapsed.
267    pub visible_start: usize,
268    /// Number of items to show at end when collapsed.
269    pub visible_end: usize,
270
271    /// Style for normal items.
272    pub item_style: Style,
273    /// Style for keyboard-focused item.
274    pub focused_item_style: Style,
275    /// Style for currently selected/active item.
276    pub selected_item_style: Style,
277    /// Style for mouse-hovered item.
278    pub hovered_item_style: Style,
279    /// Style for disabled items.
280    pub disabled_item_style: Style,
281    /// Special style for the last item (current location).
282    pub last_item_style: Style,
283
284    /// Style for icons.
285    pub icon_style: Style,
286    /// Separator between icon and label.
287    pub icon_separator: &'static str,
288
289    /// Horizontal padding (left, right).
290    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    /// Style with slash separator (Unix path style).
362    pub fn slash() -> Self {
363        Self {
364            separator: " / ",
365            ..Default::default()
366        }
367    }
368
369    /// Style with chevron separator (Unicode).
370    pub fn chevron() -> Self {
371        Self {
372            separator: " › ",
373            separator_style: Style::default().fg(Color::Gray),
374            ..Default::default()
375        }
376    }
377
378    /// Style with arrow separator (Unicode).
379    pub fn arrow() -> Self {
380        Self {
381            separator: " → ",
382            separator_style: Style::default().fg(Color::Gray),
383            ..Default::default()
384        }
385    }
386
387    /// Minimal style with subdued colors.
388    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    /// Set the separator string.
404    pub fn separator(mut self, sep: &'static str) -> Self {
405        self.separator = sep;
406        self
407    }
408
409    /// Set the separator style.
410    pub fn separator_style(mut self, style: Style) -> Self {
411        self.separator_style = style;
412        self
413    }
414
415    /// Set the collapse threshold.
416    pub fn collapse_threshold(mut self, threshold: usize) -> Self {
417        self.collapse_threshold = threshold;
418        self
419    }
420
421    /// Set visible items when collapsed (start_count, end_count).
422    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    /// Set the item style.
429    pub fn item_style(mut self, style: Style) -> Self {
430        self.item_style = style;
431        self
432    }
433
434    /// Set the focused item style.
435    pub fn focused_item_style(mut self, style: Style) -> Self {
436        self.focused_item_style = style;
437        self
438    }
439
440    /// Set the last item style.
441    pub fn last_item_style(mut self, style: Style) -> Self {
442        self.last_item_style = style;
443        self
444    }
445
446    /// Set padding (horizontal, vertical).
447    pub fn padding(mut self, left: u16, right: u16) -> Self {
448        self.padding = (left, right);
449        self
450    }
451}
452
453/// Represents a visible element in the rendered breadcrumb.
454#[derive(Debug, Clone)]
455enum VisibleElement {
456    /// A regular item with its index.
457    Item(usize),
458    /// The ellipsis element.
459    Ellipsis,
460}
461
462/// Breadcrumb widget - hierarchical navigation path display.
463///
464/// Displays a breadcrumb trail showing the user's current location in a hierarchy.
465/// Supports collapsing long paths with ellipsis, keyboard navigation, and mouse clicks.
466pub struct Breadcrumb<'a> {
467    state: &'a BreadcrumbState,
468    style: BreadcrumbStyle,
469    /// Index of currently hovered item (for mouse hover effects).
470    hovered_index: Option<usize>,
471}
472
473impl<'a> Breadcrumb<'a> {
474    /// Create a new breadcrumb widget.
475    pub fn new(state: &'a BreadcrumbState) -> Self {
476        Self {
477            state,
478            style: BreadcrumbStyle::default(),
479            hovered_index: None,
480        }
481    }
482
483    /// Set the style.
484    pub fn style(mut self, style: BreadcrumbStyle) -> Self {
485        self.style = style;
486        self
487    }
488
489    /// Apply a theme to derive the style.
490    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
491        self.style(BreadcrumbStyle::from(theme))
492    }
493
494    /// Set the hovered item index (for mouse hover effects).
495    pub fn hovered(mut self, index: Option<usize>) -> Self {
496        self.hovered_index = index;
497        self
498    }
499
500    /// Determine which elements are visible based on collapse logic.
501    fn visible_elements(&self) -> Vec<VisibleElement> {
502        let len = self.state.items.len();
503
504        // No collapsing needed
505        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        // Show first visible_start items
512        for i in 0..self.style.visible_start.min(len) {
513            elements.push(VisibleElement::Item(i));
514        }
515
516        // Add ellipsis
517        elements.push(VisibleElement::Ellipsis);
518
519        // Show last visible_end items
520        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    /// Get the style for an item at the given index.
529    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    /// Render the breadcrumb and return click regions.
552    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        // Track positions for click regions
568        let mut element_positions: Vec<(VisibleElement, u16, u16)> = Vec::new();
569
570        for (i, element) in visible.iter().enumerate() {
571            // Add separator before items (except first)
572            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                    // Build item text
585                    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        // Create the line and render
609        let line = Line::from(spans);
610        let paragraph = Paragraph::new(line);
611        paragraph.render(area, buf);
612
613        // Create click regions
614        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    /// Calculate the width needed to render the breadcrumb.
644    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            // Add separator width (except first)
654            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
677/// Handle keyboard events for breadcrumb component.
678///
679/// Returns `Some(BreadcrumbAction)` if an action was triggered, `None` otherwise.
680///
681/// # Key Bindings
682///
683/// - `←` / `h` - Select previous item
684/// - `→` / `l` - Select next item
685/// - `Enter` / `Space` - Activate selected item
686/// - `Home` - Select first item
687/// - `End` - Select last item
688/// - `e` - Expand/collapse ellipsis
689pub 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
733/// Handle mouse events for breadcrumb component.
734///
735/// Returns `Some(BreadcrumbAction)` if an action was triggered, `None` otherwise.
736///
737/// # Arguments
738///
739/// * `mouse` - The mouse event
740/// * `state` - Mutable reference to breadcrumb state
741/// * `regions` - Click regions from `render_stateful`
742pub 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 &region.data {
758                    BreadcrumbAction::Navigate(id) => {
759                        // Update selection to clicked item
760                        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
775/// Get the item index at a given mouse position.
776///
777/// Useful for implementing hover effects.
778pub 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)); // Stay at start
830
831        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)); // Unchanged
852    }
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)); // Adjusted
870    }
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        // Create breadcrumb with 6 items (above default threshold of 4)
916        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        // Default: visible_start=1, visible_end=2
923        // Should show: Item0, ..., Item4, Item5
924        let visible = breadcrumb.visible_elements();
925        assert_eq!(visible.len(), 4); // 1 start + ellipsis + 2 end
926
927        // When expanded, should show all
928        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        // Create breadcrumb with 3 items (below threshold)
938        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); // All items shown
946    }
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        // Right arrow
959        let key = KeyEvent::from(KeyCode::Right);
960        handle_breadcrumb_key(&key, &mut state);
961        assert_eq!(state.selected_index, Some(0));
962
963        // Right arrow again
964        handle_breadcrumb_key(&key, &mut state);
965        assert_eq!(state.selected_index, Some(1));
966
967        // Enter to navigate
968        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        // "Home" (4) + " > " (3) + "Settings" (8) + padding (1+1) = 17
996        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}