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