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