Skip to main content

photon_ui/components/
status_bar.rs

1//! Status bar component.
2//!
3//! Renders as a single line with left-aligned, center-aligned, and
4//! right-aligned segments. Each zone may contain multiple segments joined by
5//! two spaces.
6
7use 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/// A single piece of text in a status bar zone.
24#[derive(Clone)]
25pub struct Segment {
26    text: String,
27    style: Style,
28}
29
30impl Segment {
31    /// Create a new segment with the given text.
32    ///
33    /// The default style is the theme's secondary text color; override it with
34    /// [`styled`](Segment::styled).
35    pub fn new(text: impl Into<String>) -> Self {
36        Self {
37            text: text.into(),
38            style: Style::default(),
39        }
40    }
41
42    /// Set a custom style for this segment.
43    pub fn styled(mut self, style: Style) -> Self {
44        self.style = style;
45        self
46    }
47}
48
49/// A non-interactive status bar with three alignment zones.
50///
51/// Segments in the `left` zone are rendered at the left edge, `center`
52/// segments are centered, and `right` segments are at the right edge.
53/// Multiple segments within the same zone are joined with two spaces.
54pub struct StatusBar {
55    left: Vec<Segment>,
56    center: Vec<Segment>,
57    right: Vec<Segment>,
58}
59
60impl StatusBar {
61    /// Create an empty status bar.
62    pub fn new() -> Self {
63        Self {
64            left: Vec::new(),
65            center: Vec::new(),
66            right: Vec::new(),
67        }
68    }
69
70    /// Add a segment to the left zone.
71    pub fn left(mut self, segment: Segment) -> Self {
72        self.left.push(segment);
73        self
74    }
75
76    /// Add a segment to the center zone.
77    pub fn center(mut self, segment: Segment) -> Self {
78        self.center.push(segment);
79        self
80    }
81
82    /// Add a segment to the right zone.
83    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(&center_str);
123
124        let mut left = left_str;
125        let mut right = right_str;
126        let mut center = center_str;
127
128        // Reserve minimum space for right and center so they remain visible.
129        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        // Cap left to remaining space after reservations.
142        if left_w > left_max {
143            left = truncate_to_width(&left, left_max as u16, "…");
144            left_w = visible_width(&left);
145        }
146
147        // Cap right to remaining space after left.
148        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        // Center gets whatever is between left and right.
160        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(&center, avail_center as u16, "…");
166                center_w = visible_width(&center);
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(&center);
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            // L1 should be at the left edge (visual position 0).
245            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            // R1 should be near the right edge (visual position 18).
260            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            // "C1" is 2 chars, centered in 20 -> visual pos around 9
276            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            // Light theme text_secondary is #666666 = 102,102,102
327            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            // Light theme accent is SUNBEAM_ORANGE (#fa520f = 250,82,15)
339            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            // Plain text should contain "A  B" (two spaces between segments)
352            // Just search for the two segment texts with sufficient spacing.
353            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}