Skip to main content

photon_ui/components/
breadcrumbs.rs

1//! Breadcrumbs component.
2//!
3//! Renders a trail of navigational items separated by a configurable delimiter.
4
5use crate::{
6    Component,
7    RenderError,
8    Rendered,
9    theme::{
10        Style,
11        Theme,
12        stylize,
13    },
14};
15
16/// A non-interactive breadcrumbs trail.
17///
18/// Renders as `Home > Settings > Account` where the last item is bold and
19/// uses the primary text color, earlier items use the secondary text color,
20/// and the separator uses the default border color.
21pub struct Breadcrumbs {
22    items: Vec<String>,
23    separator: String,
24}
25
26impl Breadcrumbs {
27    /// Create a new breadcrumbs component with the given items.
28    pub fn new(items: Vec<impl Into<String>>) -> Self {
29        Self {
30            items: items.into_iter().map(Into::into).collect(),
31            separator: " > ".to_string(),
32        }
33    }
34
35    /// Set a custom separator (default is `" > "`).
36    pub fn separator(mut self, sep: impl Into<String>) -> Self {
37        self.separator = sep.into();
38        self
39    }
40}
41
42impl Component for Breadcrumbs {
43    fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
44        let theme = Theme::palette();
45        let primary_style = Style::new().fg(theme.text()).bold();
46        let secondary_style = Style::new().fg(theme.text_muted());
47        let separator_style = Style::new().fg(theme.border());
48
49        let mut parts: Vec<String> = Vec::new();
50        let count = self.items.len();
51
52        for (i, item) in self.items.iter().enumerate() {
53            if i > 0 {
54                parts.push(stylize(&self.separator, &separator_style));
55            }
56
57            let styled = if i == count.saturating_sub(1) {
58                stylize(item, &primary_style)
59            } else {
60                stylize(item, &secondary_style)
61            };
62            parts.push(styled);
63        }
64
65        let line = parts.concat();
66
67        Ok(Rendered {
68            lines: vec![line],
69            cursor: None,
70            images: Vec::new(),
71        })
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::theme::Theme;
79
80    #[test]
81    fn renders_single_item_bold() {
82        Theme::with(Theme::Light, || {
83            let bc = Breadcrumbs::new(vec!["Home"]);
84            let rendered = bc.render(80).unwrap();
85            assert_eq!(rendered.lines.len(), 1);
86            let line = &rendered.lines[0];
87            assert!(line.contains("Home"));
88            assert!(line.contains("\x1b[1m"));
89        });
90    }
91
92    #[test]
93    fn renders_multiple_items_with_separator() {
94        Theme::with(Theme::Light, || {
95            let bc = Breadcrumbs::new(vec!["Home", "Settings", "Account"]);
96            let rendered = bc.render(80).unwrap();
97            let line = &rendered.lines[0];
98
99            assert!(line.contains("Home"));
100            assert!(line.contains("Settings"));
101            assert!(line.contains("Account"));
102            assert!(line.contains(" > "));
103        });
104    }
105
106    #[test]
107    fn custom_separator() {
108        Theme::with(Theme::Light, || {
109            let bc = Breadcrumbs::new(vec!["A", "B"]).separator(" / ");
110            let rendered = bc.render(80).unwrap();
111            let line = &rendered.lines[0];
112
113            assert!(line.contains(" / "));
114            assert!(!line.contains(" > "));
115        });
116    }
117
118    #[test]
119    fn empty_items_renders_empty_line() {
120        Theme::with(Theme::Light, || {
121            let bc = Breadcrumbs::new(Vec::<String>::new());
122            let rendered = bc.render(80).unwrap();
123            assert_eq!(rendered.lines.len(), 1);
124            assert_eq!(rendered.lines[0], "");
125        });
126    }
127
128    #[test]
129    fn uses_primary_color_for_last_item() {
130        Theme::with(Theme::Light, || {
131            let bc = Breadcrumbs::new(vec!["Home", "Settings"]);
132            let rendered = bc.render(80).unwrap();
133            let line = &rendered.lines[0];
134
135            // Light theme text_primary is SUNBEAM_BLACK (#1f1f1f = 31,31,31)
136            assert!(line.contains("\x1b[38;2;31;31;31m"));
137        });
138    }
139
140    #[test]
141    fn uses_secondary_color_for_earlier_items() {
142        Theme::with(Theme::Light, || {
143            let bc = Breadcrumbs::new(vec!["Home", "Settings"]);
144            let rendered = bc.render(80).unwrap();
145            let line = &rendered.lines[0];
146
147            // Light theme text_secondary is #666666 = 102,102,102
148            assert!(line.contains("\x1b[38;2;102;102;102m"));
149        });
150    }
151
152    #[test]
153    fn last_item_is_bold() {
154        Theme::with(Theme::Light, || {
155            let bc = Breadcrumbs::new(vec!["Home", "Settings"]);
156            let rendered = bc.render(80).unwrap();
157            let line = &rendered.lines[0];
158
159            // Bold escape sequence
160            assert!(line.contains("\x1b[1m"));
161        });
162    }
163}