photon_ui/components/
status_bar.rs1use crate::{
8 Component,
9 RenderError,
10 Rendered,
11 theme::{
12 Style,
13 Theme,
14 stylize,
15 },
16 utils::{
17 truncate_to_width,
18 visible_width,
19 },
20};
21
22#[derive(Clone)]
24pub struct Segment {
25 text: String,
26 style: Style,
27}
28
29impl Segment {
30 pub fn new(text: impl Into<String>) -> Self {
35 Self {
36 text: text.into(),
37 style: Style::default(),
38 }
39 }
40
41 pub fn styled(mut self, style: Style) -> Self {
43 self.style = style;
44 self
45 }
46}
47
48pub struct StatusBar {
54 left: Vec<Segment>,
55 center: Vec<Segment>,
56 right: Vec<Segment>,
57}
58
59impl StatusBar {
60 pub fn new() -> Self {
62 Self {
63 left: Vec::new(),
64 center: Vec::new(),
65 right: Vec::new(),
66 }
67 }
68
69 pub fn left(mut self, segment: Segment) -> Self {
71 self.left.push(segment);
72 self
73 }
74
75 pub fn center(mut self, segment: Segment) -> Self {
77 self.center.push(segment);
78 self
79 }
80
81 pub fn right(mut self, segment: Segment) -> Self {
83 self.right.push(segment);
84 self
85 }
86}
87
88impl Default for StatusBar {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94fn join_zone(segments: &[Segment], default_style: Style) -> String {
95 segments
96 .iter()
97 .map(|s| {
98 let style = if s.style == Style::default() {
99 default_style
100 } else {
101 s.style
102 };
103 stylize(&s.text, &style)
104 })
105 .collect::<Vec<_>>()
106 .join(" ")
107}
108
109impl Component for StatusBar {
110 fn render(&self, width: u16) -> Result<Rendered, RenderError> {
111 let theme = Theme::palette();
112 let default_style = Style::new().fg(theme.text_muted());
113
114 let left_str = join_zone(&self.left, default_style);
115 let center_str = join_zone(&self.center, default_style);
116 let right_str = join_zone(&self.right, default_style);
117
118 let width_usize = width as usize;
119 let mut left_w = visible_width(&left_str);
120 let mut right_w = visible_width(&right_str);
121 let mut center_w = visible_width(¢er_str);
122
123 let mut left = left_str;
124 let mut right = right_str;
125 let mut center = center_str;
126
127 let min_right = if self.right.is_empty() {
129 0
130 } else {
131 right_w.min(5)
132 };
133 let min_center = if self.center.is_empty() {
134 0
135 } else {
136 center_w.min(5)
137 };
138 let left_max = width_usize.saturating_sub(min_right + min_center);
139
140 if left_w > left_max {
142 left = truncate_to_width(&left, left_max as u16, "…");
143 left_w = visible_width(&left);
144 }
145
146 let avail_right = width_usize.saturating_sub(left_w);
148 if right_w > avail_right {
149 if avail_right > 0 {
150 right = truncate_to_width(&right, avail_right as u16, "…");
151 right_w = visible_width(&right);
152 } else {
153 right = String::new();
154 right_w = 0;
155 }
156 }
157
158 let middle_start = left_w;
160 let middle_end = width_usize.saturating_sub(right_w);
161 let avail_center = middle_end.saturating_sub(middle_start);
162 if center_w > avail_center {
163 if avail_center > 0 {
164 center = truncate_to_width(¢er, avail_center as u16, "…");
165 center_w = visible_width(¢er);
166 } else {
167 center = String::new();
168 center_w = 0;
169 }
170 }
171
172 let mut line = left;
173
174 if center_w > 0 {
175 let center_pos = middle_start + (avail_center.saturating_sub(center_w)) / 2;
176 let current_w = visible_width(&line);
177 if center_pos > current_w {
178 line.push_str(&" ".repeat(center_pos - current_w));
179 }
180 line.push_str(¢er);
181 }
182
183 if right_w > 0 {
184 let right_pos = width_usize - right_w;
185 let current_w = visible_width(&line);
186 if right_pos > current_w {
187 line.push_str(&" ".repeat(right_pos - current_w));
188 }
189 line.push_str(&right);
190 }
191
192 let current_w = visible_width(&line);
193 if current_w < width_usize {
194 line.push_str(&" ".repeat(width_usize - current_w));
195 }
196
197 Ok(Rendered {
198 lines: vec![line],
199 cursor: None,
200 images: Vec::new(),
201 })
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::theme::Theme;
209
210 #[test]
211 fn new_creates_empty() {
212 let bar = StatusBar::new();
213 assert!(bar.left.is_empty());
214 assert!(bar.center.is_empty());
215 assert!(bar.right.is_empty());
216 }
217
218 #[test]
219 fn default_creates_empty() {
220 let bar: StatusBar = Default::default();
221 assert!(bar.left.is_empty());
222 assert!(bar.center.is_empty());
223 assert!(bar.right.is_empty());
224 }
225
226 #[test]
227 fn renders_empty() {
228 Theme::with(Theme::Light, || {
229 let bar = StatusBar::new();
230 let rendered = bar.render(20).unwrap();
231 assert_eq!(rendered.lines.len(), 1);
232 assert_eq!(visible_width(&rendered.lines[0]), 20);
233 });
234 }
235
236 #[test]
237 fn renders_left_only() {
238 Theme::with(Theme::Light, || {
239 let bar = StatusBar::new().left(Segment::new("L1"));
240 let rendered = bar.render(20).unwrap();
241 let line = &rendered.lines[0];
242 assert!(line.contains("L1"));
243 let l1_pos = line.find("L1").unwrap();
245 let visual_pos = visible_width(&line[..l1_pos]);
246 assert_eq!(visual_pos, 0);
247 assert_eq!(visible_width(line), 20);
248 });
249 }
250
251 #[test]
252 fn renders_right_only() {
253 Theme::with(Theme::Light, || {
254 let bar = StatusBar::new().right(Segment::new("R1"));
255 let rendered = bar.render(20).unwrap();
256 let line = &rendered.lines[0];
257 assert!(line.contains("R1"));
258 let r1_pos = line.find("R1").unwrap();
260 let visual_pos = visible_width(&line[..r1_pos]);
261 assert!(visual_pos >= 16, "right segment should be near the edge");
262 assert_eq!(visible_width(line), 20);
263 });
264 }
265
266 #[test]
267 fn renders_center_only() {
268 Theme::with(Theme::Light, || {
269 let bar = StatusBar::new().center(Segment::new("C1"));
270 let rendered = bar.render(20).unwrap();
271 let line = &rendered.lines[0];
272 assert!(line.contains("C1"));
273 let c1_pos = line.find("C1").unwrap();
274 let visual_pos = visible_width(&line[..c1_pos]);
276 assert!((8..=10).contains(&visual_pos));
277 assert_eq!(visible_width(line), 20);
278 });
279 }
280
281 #[test]
282 fn renders_left_and_right() {
283 Theme::with(Theme::Light, || {
284 let bar = StatusBar::new()
285 .left(Segment::new("L1"))
286 .right(Segment::new("R1"));
287 let rendered = bar.render(20).unwrap();
288 let line = &rendered.lines[0];
289 assert!(line.contains("L1"));
290 assert!(line.contains("R1"));
291 let l1_pos = line.find("L1").unwrap();
292 let r1_pos = line.find("R1").unwrap();
293 assert!(l1_pos < r1_pos);
294 assert_eq!(visible_width(line), 20);
295 });
296 }
297
298 #[test]
299 fn renders_all_zones() {
300 Theme::with(Theme::Light, || {
301 let bar = StatusBar::new()
302 .left(Segment::new("L1"))
303 .center(Segment::new("C1"))
304 .right(Segment::new("R1"));
305 let rendered = bar.render(30).unwrap();
306 let line = &rendered.lines[0];
307 assert!(line.contains("L1"));
308 assert!(line.contains("C1"));
309 assert!(line.contains("R1"));
310 let l1_pos = line.find("L1").unwrap();
311 let c1_pos = line.find("C1").unwrap();
312 let r1_pos = line.find("R1").unwrap();
313 assert!(l1_pos < c1_pos);
314 assert!(c1_pos < r1_pos);
315 assert_eq!(visible_width(line), 30);
316 });
317 }
318
319 #[test]
320 fn default_style_is_secondary() {
321 Theme::with(Theme::Light, || {
322 let bar = StatusBar::new().left(Segment::new("x"));
323 let rendered = bar.render(20).unwrap();
324 let line = &rendered.lines[0];
325 assert!(line.contains("\x1b[38;2;102;102;102m"));
327 });
328 }
329
330 #[test]
331 fn custom_style_overrides_default() {
332 Theme::with(Theme::Light, || {
333 let accent_style = Style::new().fg(Theme::palette().accent());
334 let bar = StatusBar::new().left(Segment::new("x").styled(accent_style));
335 let rendered = bar.render(20).unwrap();
336 let line = &rendered.lines[0];
337 assert!(line.contains("\x1b[38;2;250;82;15m"));
339 });
340 }
341
342 #[test]
343 fn multiple_segments_joined() {
344 Theme::with(Theme::Light, || {
345 let bar = StatusBar::new()
346 .left(Segment::new("A"))
347 .left(Segment::new("B"));
348 let rendered = bar.render(20).unwrap();
349 let line = &rendered.lines[0];
350 let a_pos = line.find('A').unwrap();
353 let b_pos = line.find('B').unwrap();
354 assert!(b_pos > a_pos);
355 assert!(line[a_pos..b_pos].contains(" "));
356 });
357 }
358
359 #[test]
360 fn truncates_when_too_wide() {
361 Theme::with(Theme::Light, || {
362 let bar = StatusBar::new()
363 .left(Segment::new("VeryLongLeft"))
364 .right(Segment::new("VeryLongRight"));
365 let rendered = bar.render(15).unwrap();
366 let line = &rendered.lines[0];
367 assert!(visible_width(line) <= 15);
368 });
369 }
370}