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