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