Skip to main content

photon_ui/components/
header.rs

1//! Header component.
2//!
3//! Renders a left-aligned title with optional right-aligned action labels.
4
5use crate::{
6    Component,
7    RenderError,
8    Rendered,
9    theme::{
10        Palette,
11        Style,
12        Theme,
13        stylize,
14    },
15    utils::{
16        truncate_to_width,
17        visible_width,
18    },
19};
20
21/// A non-interactive header bar.
22///
23/// Renders as `Title          [Action1] [Action2]` where the title is
24/// left-aligned, bold, and uses the primary text color; actions are
25/// right-aligned and use the secondary text color.
26pub struct Header {
27    title: String,
28    actions: Vec<String>,
29}
30
31impl Header {
32    /// Create a new header with the given title.
33    pub fn new(title: impl Into<String>) -> Self {
34        Self {
35            title: title.into(),
36            actions: Vec::new(),
37        }
38    }
39
40    /// Add a right-aligned action label.
41    ///
42    /// Actions are rendered as `[label]`.
43    pub fn action(mut self, label: impl Into<String>) -> Self {
44        self.actions.push(label.into());
45        self
46    }
47}
48
49impl Component for Header {
50    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
51        let theme = Theme::current();
52        let primary_style = Style::new().fg(theme.text_primary()).bold();
53        let secondary_style = Style::new().fg(theme.text_secondary());
54
55        let action_labels: Vec<String> = self.actions.iter().map(|a| format!("[{}]", a)).collect();
56        let actions_plain = action_labels.join(" ");
57        let actions_vw = visible_width(&actions_plain);
58
59        let title_vw = visible_width(&self.title);
60
61        let line = if self.actions.is_empty() {
62            let title_text = if title_vw > width as usize {
63                truncate_to_width(&self.title, width, "…")
64            } else {
65                self.title.clone()
66            };
67            stylize(&title_text, &primary_style)
68        } else {
69            let padding_width = 1usize;
70            let total_needed = title_vw + padding_width + actions_vw;
71
72            let title_text = if total_needed > width as usize {
73                let avail = (width as usize).saturating_sub(padding_width + actions_vw);
74                truncate_to_width(&self.title, avail as u16, "…")
75            } else {
76                self.title.clone()
77            };
78
79            let title_styled = stylize(&title_text, &primary_style);
80            let actions_styled = action_labels
81                .iter()
82                .map(|a| stylize(a, &secondary_style))
83                .collect::<Vec<_>>()
84                .join(" ");
85
86            let title_styled_vw = visible_width(&title_styled);
87            let actions_styled_vw = visible_width(&actions_styled);
88            let pad_len = (width as usize).saturating_sub(title_styled_vw + actions_styled_vw);
89
90            format!("{}{}{}", title_styled, " ".repeat(pad_len), actions_styled)
91        };
92
93        Ok(Rendered {
94            lines: vec![line],
95            cursor: None,
96            images: Vec::new(),
97        })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::theme::Theme;
105
106    #[test]
107    fn renders_title_only() {
108        Theme::with(Theme::Light, || {
109            let header = Header::new("My App");
110            let rendered = header.render(20).unwrap();
111            assert_eq!(rendered.lines.len(), 1);
112            assert!(rendered.lines[0].contains("My App"));
113        });
114    }
115
116    #[test]
117    fn renders_actions_right_aligned() {
118        Theme::with(Theme::Light, || {
119            let header = Header::new("My App").action("Save").action("Delete");
120            let rendered = header.render(40).unwrap();
121            let line = &rendered.lines[0];
122
123            assert!(line.contains("My App"));
124            assert!(line.contains("[Save]"));
125            assert!(line.contains("[Delete]"));
126
127            let title_pos = line.find("My App").unwrap();
128            let save_pos = line.find("[Save]").unwrap();
129            assert!(save_pos > title_pos);
130        });
131    }
132
133    #[test]
134    fn title_uses_primary_color_and_bold() {
135        Theme::with(Theme::Light, || {
136            let header = Header::new("Title");
137            let rendered = header.render(20).unwrap();
138            let line = &rendered.lines[0];
139            // Light theme text_primary is #1f1f1f = 31,31,31
140            assert!(line.contains("\x1b[38;2;31;31;31m"));
141            assert!(line.contains("\x1b[1m"));
142        });
143    }
144
145    #[test]
146    fn actions_use_secondary_color() {
147        Theme::with(Theme::Light, || {
148            let header = Header::new("Title").action("Help");
149            let rendered = header.render(20).unwrap();
150            let line = &rendered.lines[0];
151            // Light theme text_secondary is #666666 = 102,102,102
152            assert!(line.contains("\x1b[38;2;102;102;102m"));
153        });
154    }
155
156    #[test]
157    fn truncates_title_when_too_wide() {
158        Theme::with(Theme::Light, || {
159            let header = Header::new("Very Long Title Indeed").action("X");
160            let rendered = header.render(15).unwrap();
161            let line = &rendered.lines[0];
162            assert!(visible_width(line) <= 15);
163            assert!(line.contains("[X]"));
164        });
165    }
166
167    #[test]
168    fn empty_actions_renders_title_only() {
169        Theme::with(Theme::Light, || {
170            let header = Header::new("Only Title");
171            let rendered = header.render(20).unwrap();
172            assert!(rendered.lines[0].contains("Only Title"));
173            // No action labels like [Save] should appear
174            assert!(!rendered.lines[0].contains("[Only Title]"));
175        });
176    }
177}