photon_ui/components/
header.rs1use 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
21pub struct Header {
27 title: String,
28 actions: Vec<String>,
29}
30
31impl Header {
32 pub fn new(title: impl Into<String>) -> Self {
34 Self {
35 title: title.into(),
36 actions: Vec::new(),
37 }
38 }
39
40 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 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 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 assert!(!rendered.lines[0].contains("[Only Title]"));
175 });
176 }
177}