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