Skip to main content

ftui_widgets/
status_line.rs

1#![forbid(unsafe_code)]
2
3//! Status line widget for agent harness UIs.
4//!
5//! Provides a horizontal status bar with left, center, and right regions
6//! that can contain text, spinners, progress indicators, and key hints.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ftui_widgets::status_line::{StatusLine, StatusItem};
12//!
13//! let status = StatusLine::new()
14//!     .left(StatusItem::text("[INSERT]"))
15//!     .center(StatusItem::text("file.rs"))
16//!     .right(StatusItem::key_hint("^C", "Quit"))
17//!     .right(StatusItem::text("Ln 42, Col 10"));
18//! ```
19
20use crate::{Widget, apply_style, draw_text_span};
21use ftui_core::geometry::Rect;
22use ftui_render::cell::Cell;
23use ftui_render::frame::Frame;
24use ftui_style::Style;
25use ftui_text::display_width;
26
27/// An item that can be displayed in the status line.
28#[derive(Debug, Clone)]
29pub enum StatusItem<'a> {
30    /// Plain text.
31    Text(&'a str),
32    /// A spinner showing activity (references spinner state by index).
33    Spinner(usize),
34    /// A progress indicator showing current/total.
35    Progress {
36        /// Current progress value.
37        current: u64,
38        /// Total progress value.
39        total: u64,
40    },
41    /// A key hint showing a key and its action.
42    KeyHint {
43        /// Key binding label.
44        key: &'a str,
45        /// Description of the action.
46        action: &'a str,
47    },
48    /// A flexible spacer that expands to fill available space.
49    Spacer,
50}
51
52impl<'a> StatusItem<'a> {
53    /// Create a text item.
54    pub const fn text(s: &'a str) -> Self {
55        Self::Text(s)
56    }
57
58    /// Create a key hint item.
59    pub const fn key_hint(key: &'a str, action: &'a str) -> Self {
60        Self::KeyHint { key, action }
61    }
62
63    /// Create a progress item.
64    pub const fn progress(current: u64, total: u64) -> Self {
65        Self::Progress { current, total }
66    }
67
68    /// Create a spacer item.
69    pub const fn spacer() -> Self {
70        Self::Spacer
71    }
72
73    /// Calculate the display width of this item.
74    fn width(&self) -> usize {
75        match self {
76            Self::Text(s) => display_width(s),
77            Self::Spinner(_) => 1, // Single char spinner
78            Self::Progress { current, total } => {
79                // Format: "42/100" or "100%"
80                let pct = current.saturating_mul(100).checked_div(*total).unwrap_or(0);
81                format!("{pct}%").len()
82            }
83            Self::KeyHint { key, action } => {
84                // Format: "^C Quit"
85                display_width(key) + 1 + display_width(action)
86            }
87            Self::Spacer => 0, // Spacer has no fixed width
88        }
89    }
90
91    /// Render this item to a string.
92    fn render_to_string(&self) -> String {
93        match self {
94            Self::Text(s) => (*s).to_string(),
95            Self::Spinner(idx) => {
96                // Simple spinner frames
97                const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
98                FRAMES[*idx % FRAMES.len()].to_string()
99            }
100            Self::Progress { current, total } => {
101                let pct = current.saturating_mul(100).checked_div(*total).unwrap_or(0);
102                format!("{pct}%")
103            }
104            Self::KeyHint { key, action } => {
105                format!("{key} {action}")
106            }
107            Self::Spacer => String::new(),
108        }
109    }
110}
111
112/// A status line widget with left, center, and right regions.
113#[derive(Debug, Clone, Default)]
114pub struct StatusLine<'a> {
115    left: Vec<StatusItem<'a>>,
116    center: Vec<StatusItem<'a>>,
117    right: Vec<StatusItem<'a>>,
118    style: Style,
119    separator: &'a str,
120}
121
122impl<'a> StatusLine<'a> {
123    /// Create a new empty status line.
124    pub fn new() -> Self {
125        Self {
126            left: Vec::new(),
127            center: Vec::new(),
128            right: Vec::new(),
129            style: Style::default(),
130            separator: " ",
131        }
132    }
133
134    /// Add an item to the left region.
135    #[must_use]
136    pub fn left(mut self, item: StatusItem<'a>) -> Self {
137        self.left.push(item);
138        self
139    }
140
141    /// Add an item to the center region.
142    #[must_use]
143    pub fn center(mut self, item: StatusItem<'a>) -> Self {
144        self.center.push(item);
145        self
146    }
147
148    /// Add an item to the right region.
149    #[must_use]
150    pub fn right(mut self, item: StatusItem<'a>) -> Self {
151        self.right.push(item);
152        self
153    }
154
155    /// Set the overall style for the status line.
156    #[must_use]
157    pub fn style(mut self, style: Style) -> Self {
158        self.style = style;
159        self
160    }
161
162    /// Set the separator between items (default: " ").
163    #[must_use]
164    pub fn separator(mut self, separator: &'a str) -> Self {
165        self.separator = separator;
166        self
167    }
168
169    /// Calculate total fixed width (non-spacers, with separators between non-spacers).
170    fn items_fixed_width(&self, items: &[StatusItem]) -> usize {
171        let sep_width = display_width(self.separator);
172        let mut width = 0usize;
173        let mut prev_item = false;
174
175        for item in items {
176            if matches!(item, StatusItem::Spacer) {
177                prev_item = false;
178                continue;
179            }
180
181            if prev_item {
182                width += sep_width;
183            }
184            width += item.width();
185            prev_item = true;
186        }
187
188        width
189    }
190
191    /// Count flexible spacers in an item list.
192    fn spacer_count(&self, items: &[StatusItem]) -> usize {
193        items
194            .iter()
195            .filter(|item| matches!(item, StatusItem::Spacer))
196            .count()
197    }
198
199    /// Render a list of items starting at x position.
200    fn render_items(
201        &self,
202        frame: &mut Frame,
203        items: &[StatusItem],
204        mut x: u16,
205        y: u16,
206        max_x: u16,
207        style: Style,
208    ) -> u16 {
209        let available = max_x.saturating_sub(x) as usize;
210        let fixed_width = self.items_fixed_width(items);
211        let spacers = self.spacer_count(items);
212        let extra = available.saturating_sub(fixed_width);
213        let per_spacer = extra.checked_div(spacers).unwrap_or(0);
214        let mut remainder = extra.checked_rem(spacers).unwrap_or(0);
215        let mut prev_item = false;
216
217        for item in items {
218            if x >= max_x {
219                break;
220            }
221
222            if matches!(item, StatusItem::Spacer) {
223                let mut space = per_spacer;
224                if remainder > 0 {
225                    space += 1;
226                    remainder -= 1;
227                }
228                let advance = (space as u16).min(max_x.saturating_sub(x));
229                x = x.saturating_add(advance);
230                prev_item = false;
231                continue;
232            }
233
234            // Add separator between non-spacer items
235            if prev_item && !self.separator.is_empty() {
236                x = draw_text_span(frame, x, y, self.separator, style, max_x);
237                if x >= max_x {
238                    break;
239                }
240            }
241
242            let text = item.render_to_string();
243            x = draw_text_span(frame, x, y, &text, style, max_x);
244            prev_item = true;
245        }
246
247        x
248    }
249}
250
251impl Widget for StatusLine<'_> {
252    fn render(&self, area: Rect, frame: &mut Frame) {
253        #[cfg(feature = "tracing")]
254        let _span = tracing::debug_span!(
255            "widget_render",
256            widget = "StatusLine",
257            x = area.x,
258            y = area.y,
259            w = area.width,
260            h = area.height
261        )
262        .entered();
263
264        if area.is_empty() || area.height < 1 {
265            return;
266        }
267
268        let deg = frame.buffer.degradation;
269
270        // StatusLine is essential (user needs to see status)
271        if !deg.render_content() {
272            return;
273        }
274
275        let style = if deg.apply_styling() {
276            self.style
277        } else {
278            Style::default()
279        };
280
281        // Fill the background
282        for x in area.x..area.right() {
283            let mut cell = Cell::from_char(' ');
284            apply_style(&mut cell, style);
285            frame.buffer.set_fast(x, area.y, cell);
286        }
287
288        let width = area.width as usize;
289        let left_width = self.items_fixed_width(&self.left);
290        let center_width = self.items_fixed_width(&self.center);
291        let right_width = self.items_fixed_width(&self.right);
292        let center_spacers = self.spacer_count(&self.center);
293
294        // Calculate positions
295        let left_x = area.x;
296        let right_x = area.right().saturating_sub(right_width as u16);
297        let available_center = width.saturating_sub(left_width).saturating_sub(right_width);
298        let center_target_width = if center_width > 0 && center_spacers > 0 {
299            available_center
300        } else {
301            center_width
302        };
303        let center_x = if center_width > 0 || center_spacers > 0 {
304            // Center the center items in the available space
305            let center_start =
306                left_width + available_center.saturating_sub(center_target_width) / 2;
307            area.x.saturating_add(center_start as u16)
308        } else {
309            area.x
310        };
311
312        let center_can_render = (center_width > 0 || center_spacers > 0)
313            && center_x + center_target_width as u16 <= right_x;
314        let left_max_x = if center_can_render { center_x } else { right_x };
315
316        // Render left items
317        if !self.left.is_empty() {
318            self.render_items(frame, &self.left, left_x, area.y, left_max_x, style);
319        }
320
321        // Render center items (if they fit)
322        if center_can_render {
323            self.render_items(frame, &self.center, center_x, area.y, right_x, style);
324        }
325
326        // Render right items
327        if !self.right.is_empty() && right_x >= area.x {
328            self.render_items(frame, &self.right, right_x, area.y, area.right(), style);
329        }
330    }
331
332    fn is_essential(&self) -> bool {
333        true // Status line should always render
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use ftui_render::buffer::Buffer;
341    use ftui_render::cell::PackedRgba;
342    use ftui_render::grapheme_pool::GraphemePool;
343
344    fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
345        (0..width)
346            .map(|x| {
347                buf.get(x, y)
348                    .and_then(|c| c.content.as_char())
349                    .unwrap_or(' ')
350            })
351            .collect::<String>()
352            .trim_end()
353            .to_string()
354    }
355
356    fn row_full(buf: &Buffer, y: u16, width: u16) -> String {
357        (0..width)
358            .map(|x| {
359                buf.get(x, y)
360                    .and_then(|c| c.content.as_char())
361                    .unwrap_or(' ')
362            })
363            .collect()
364    }
365
366    #[test]
367    fn empty_status_line() {
368        let status = StatusLine::new();
369        let area = Rect::new(0, 0, 20, 1);
370        let mut pool = GraphemePool::new();
371        let mut frame = Frame::new(20, 1, &mut pool);
372        status.render(area, &mut frame);
373
374        // Should just be spaces
375        let s = row_string(&frame.buffer, 0, 20);
376        assert!(s.is_empty() || s.chars().all(|c| c == ' '));
377    }
378
379    #[test]
380    fn left_only() {
381        let status = StatusLine::new().left(StatusItem::text("[INSERT]"));
382        let area = Rect::new(0, 0, 20, 1);
383        let mut pool = GraphemePool::new();
384        let mut frame = Frame::new(20, 1, &mut pool);
385        status.render(area, &mut frame);
386
387        let s = row_string(&frame.buffer, 0, 20);
388        assert!(s.starts_with("[INSERT]"), "Got: '{s}'");
389    }
390
391    #[test]
392    fn right_only() {
393        let status = StatusLine::new().right(StatusItem::text("Ln 42"));
394        let area = Rect::new(0, 0, 20, 1);
395        let mut pool = GraphemePool::new();
396        let mut frame = Frame::new(20, 1, &mut pool);
397        status.render(area, &mut frame);
398
399        let s = row_string(&frame.buffer, 0, 20);
400        assert!(s.ends_with("Ln 42"), "Got: '{s}'");
401    }
402
403    #[test]
404    fn center_only() {
405        let status = StatusLine::new().center(StatusItem::text("file.rs"));
406        let area = Rect::new(0, 0, 20, 1);
407        let mut pool = GraphemePool::new();
408        let mut frame = Frame::new(20, 1, &mut pool);
409        status.render(area, &mut frame);
410
411        let s = row_string(&frame.buffer, 0, 20);
412        assert!(s.contains("file.rs"), "Got: '{s}'");
413        // Should be roughly centered
414        let pos = s.find("file.rs").unwrap();
415        assert!(pos > 2 && pos < 15, "Not centered, pos={pos}, got: '{s}'");
416    }
417
418    #[test]
419    fn all_three_regions() {
420        let status = StatusLine::new()
421            .left(StatusItem::text("L"))
422            .center(StatusItem::text("C"))
423            .right(StatusItem::text("R"));
424        let area = Rect::new(0, 0, 20, 1);
425        let mut pool = GraphemePool::new();
426        let mut frame = Frame::new(20, 1, &mut pool);
427        status.render(area, &mut frame);
428
429        let s = row_string(&frame.buffer, 0, 20);
430        assert!(s.starts_with("L"), "Got: '{s}'");
431        assert!(s.ends_with("R"), "Got: '{s}'");
432        assert!(s.contains("C"), "Got: '{s}'");
433    }
434
435    #[test]
436    fn key_hint() {
437        let status = StatusLine::new().left(StatusItem::key_hint("^C", "Quit"));
438        let area = Rect::new(0, 0, 20, 1);
439        let mut pool = GraphemePool::new();
440        let mut frame = Frame::new(20, 1, &mut pool);
441        status.render(area, &mut frame);
442
443        let s = row_string(&frame.buffer, 0, 20);
444        assert!(s.contains("^C Quit"), "Got: '{s}'");
445    }
446
447    #[test]
448    fn progress() {
449        let status = StatusLine::new().left(StatusItem::progress(50, 100));
450        let area = Rect::new(0, 0, 20, 1);
451        let mut pool = GraphemePool::new();
452        let mut frame = Frame::new(20, 1, &mut pool);
453        status.render(area, &mut frame);
454
455        let s = row_string(&frame.buffer, 0, 20);
456        assert!(s.contains("50%"), "Got: '{s}'");
457    }
458
459    #[test]
460    fn multiple_items_left() {
461        let status = StatusLine::new()
462            .left(StatusItem::text("A"))
463            .left(StatusItem::text("B"))
464            .left(StatusItem::text("C"));
465        let area = Rect::new(0, 0, 20, 1);
466        let mut pool = GraphemePool::new();
467        let mut frame = Frame::new(20, 1, &mut pool);
468        status.render(area, &mut frame);
469
470        let s = row_string(&frame.buffer, 0, 20);
471        assert!(s.starts_with("A B C"), "Got: '{s}'");
472    }
473
474    #[test]
475    fn custom_separator() {
476        let status = StatusLine::new()
477            .separator(" | ")
478            .left(StatusItem::text("A"))
479            .left(StatusItem::text("B"));
480        let area = Rect::new(0, 0, 20, 1);
481        let mut pool = GraphemePool::new();
482        let mut frame = Frame::new(20, 1, &mut pool);
483        status.render(area, &mut frame);
484
485        let s = row_string(&frame.buffer, 0, 20);
486        assert!(s.contains("A | B"), "Got: '{s}'");
487    }
488
489    #[test]
490    fn spacer_expands_and_skips_separators() {
491        let status = StatusLine::new()
492            .separator(" | ")
493            .left(StatusItem::text("L"))
494            .left(StatusItem::spacer())
495            .left(StatusItem::text("R"));
496        let area = Rect::new(0, 0, 10, 1);
497        let mut pool = GraphemePool::new();
498        let mut frame = Frame::new(10, 1, &mut pool);
499        status.render(area, &mut frame);
500
501        let row = row_full(&frame.buffer, 0, 10);
502        let chars: Vec<char> = row.chars().collect();
503        assert_eq!(chars[0], 'L');
504        assert_eq!(chars[9], 'R');
505        assert!(
506            !row.contains('|'),
507            "Spacer should skip separators, got: '{row}'"
508        );
509    }
510
511    #[test]
512    fn style_applied() {
513        let fg = PackedRgba::rgb(255, 0, 0);
514        let status = StatusLine::new()
515            .style(Style::new().fg(fg))
516            .left(StatusItem::text("X"));
517        let area = Rect::new(0, 0, 10, 1);
518        let mut pool = GraphemePool::new();
519        let mut frame = Frame::new(10, 1, &mut pool);
520        status.render(area, &mut frame);
521
522        assert_eq!(frame.buffer.get(0, 0).unwrap().fg, fg);
523    }
524
525    #[test]
526    fn is_essential() {
527        let status = StatusLine::new();
528        assert!(status.is_essential());
529    }
530
531    #[test]
532    fn zero_area_no_panic() {
533        let status = StatusLine::new().left(StatusItem::text("Test"));
534        let area = Rect::new(0, 0, 0, 0);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(1, 1, &mut pool);
537        status.render(area, &mut frame);
538        // Should not panic
539    }
540
541    #[test]
542    fn spinner_renders_braille_char() {
543        let status = StatusLine::new().left(StatusItem::Spinner(0));
544        let area = Rect::new(0, 0, 10, 1);
545        let mut pool = GraphemePool::new();
546        let mut frame = Frame::new(10, 1, &mut pool);
547        status.render(area, &mut frame);
548
549        let c = frame
550            .buffer
551            .get(0, 0)
552            .and_then(|c| c.content.as_char())
553            .unwrap();
554        assert_eq!(c, '⠋');
555    }
556
557    #[test]
558    fn spinner_cycles_through_frames() {
559        // Frame index wraps modulo 10
560        let item0 = StatusItem::Spinner(0);
561        let item10 = StatusItem::Spinner(10);
562        assert_eq!(item0.render_to_string(), item10.render_to_string());
563
564        let item1 = StatusItem::Spinner(1);
565        assert_ne!(item0.render_to_string(), item1.render_to_string());
566    }
567
568    #[test]
569    fn spinner_width_is_one() {
570        let item = StatusItem::Spinner(5);
571        assert_eq!(item.width(), 1);
572    }
573
574    #[test]
575    fn progress_zero_total_shows_zero_percent() {
576        let item = StatusItem::progress(50, 0);
577        assert_eq!(item.render_to_string(), "0%");
578    }
579
580    #[test]
581    fn spacer_width_is_zero() {
582        assert_eq!(StatusItem::spacer().width(), 0);
583    }
584
585    #[test]
586    fn spacer_render_to_string_is_empty() {
587        assert_eq!(StatusItem::spacer().render_to_string(), "");
588    }
589
590    #[test]
591    fn status_line_default_is_empty() {
592        let status = StatusLine::default();
593        assert!(status.left.is_empty());
594        assert!(status.center.is_empty());
595        assert!(status.right.is_empty());
596        assert_eq!(status.separator, "");
597    }
598
599    #[test]
600    fn multiple_items_right() {
601        let status = StatusLine::new()
602            .right(StatusItem::text("X"))
603            .right(StatusItem::text("Y"));
604        let area = Rect::new(0, 0, 20, 1);
605        let mut pool = GraphemePool::new();
606        let mut frame = Frame::new(20, 1, &mut pool);
607        status.render(area, &mut frame);
608
609        let s = row_string(&frame.buffer, 0, 20);
610        assert!(s.contains("X Y"), "Got: '{s}'");
611    }
612
613    #[test]
614    fn key_hint_width() {
615        let item = StatusItem::key_hint("^C", "Quit");
616        // "^C" = 2 + " " = 1 + "Quit" = 4 = 7
617        assert_eq!(item.width(), 7);
618    }
619
620    #[test]
621    fn progress_full_hundred_percent() {
622        let item = StatusItem::progress(100, 100);
623        assert_eq!(item.render_to_string(), "100%");
624    }
625
626    #[test]
627    fn truncation_when_too_narrow() {
628        let status = StatusLine::new()
629            .left(StatusItem::text("VERYLONGTEXT"))
630            .right(StatusItem::text("R"));
631        let area = Rect::new(0, 0, 10, 1);
632        let mut pool = GraphemePool::new();
633        let mut frame = Frame::new(10, 1, &mut pool);
634        status.render(area, &mut frame);
635
636        // Should render what fits without panicking
637        let s = row_string(&frame.buffer, 0, 10);
638        assert!(!s.is_empty(), "Got empty string");
639    }
640}