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    pub fn left(mut self, item: StatusItem<'a>) -> Self {
136        self.left.push(item);
137        self
138    }
139
140    /// Add an item to the center region.
141    pub fn center(mut self, item: StatusItem<'a>) -> Self {
142        self.center.push(item);
143        self
144    }
145
146    /// Add an item to the right region.
147    pub fn right(mut self, item: StatusItem<'a>) -> Self {
148        self.right.push(item);
149        self
150    }
151
152    /// Set the overall style for the status line.
153    pub fn style(mut self, style: Style) -> Self {
154        self.style = style;
155        self
156    }
157
158    /// Set the separator between items (default: " ").
159    pub fn separator(mut self, separator: &'a str) -> Self {
160        self.separator = separator;
161        self
162    }
163
164    /// Calculate total fixed width (non-spacers, with separators between non-spacers).
165    fn items_fixed_width(&self, items: &[StatusItem]) -> usize {
166        let sep_width = display_width(self.separator);
167        let mut width = 0usize;
168        let mut prev_item = false;
169
170        for item in items {
171            if matches!(item, StatusItem::Spacer) {
172                prev_item = false;
173                continue;
174            }
175
176            if prev_item {
177                width += sep_width;
178            }
179            width += item.width();
180            prev_item = true;
181        }
182
183        width
184    }
185
186    /// Count flexible spacers in an item list.
187    fn spacer_count(&self, items: &[StatusItem]) -> usize {
188        items
189            .iter()
190            .filter(|item| matches!(item, StatusItem::Spacer))
191            .count()
192    }
193
194    /// Render a list of items starting at x position.
195    fn render_items(
196        &self,
197        frame: &mut Frame,
198        items: &[StatusItem],
199        mut x: u16,
200        y: u16,
201        max_x: u16,
202        style: Style,
203    ) -> u16 {
204        let available = max_x.saturating_sub(x) as usize;
205        let fixed_width = self.items_fixed_width(items);
206        let spacers = self.spacer_count(items);
207        let extra = available.saturating_sub(fixed_width);
208        let per_spacer = extra.checked_div(spacers).unwrap_or(0);
209        let mut remainder = extra.checked_rem(spacers).unwrap_or(0);
210        let mut prev_item = false;
211
212        for item in items {
213            if x >= max_x {
214                break;
215            }
216
217            if matches!(item, StatusItem::Spacer) {
218                let mut space = per_spacer;
219                if remainder > 0 {
220                    space += 1;
221                    remainder -= 1;
222                }
223                let advance = (space as u16).min(max_x.saturating_sub(x));
224                x = x.saturating_add(advance);
225                prev_item = false;
226                continue;
227            }
228
229            // Add separator between non-spacer items
230            if prev_item && !self.separator.is_empty() {
231                x = draw_text_span(frame, x, y, self.separator, style, max_x);
232                if x >= max_x {
233                    break;
234                }
235            }
236
237            let text = item.render_to_string();
238            x = draw_text_span(frame, x, y, &text, style, max_x);
239            prev_item = true;
240        }
241
242        x
243    }
244}
245
246impl Widget for StatusLine<'_> {
247    fn render(&self, area: Rect, frame: &mut Frame) {
248        #[cfg(feature = "tracing")]
249        let _span = tracing::debug_span!(
250            "widget_render",
251            widget = "StatusLine",
252            x = area.x,
253            y = area.y,
254            w = area.width,
255            h = area.height
256        )
257        .entered();
258
259        if area.is_empty() || area.height < 1 {
260            return;
261        }
262
263        let deg = frame.buffer.degradation;
264
265        // StatusLine is essential (user needs to see status)
266        if !deg.render_content() {
267            return;
268        }
269
270        let style = if deg.apply_styling() {
271            self.style
272        } else {
273            Style::default()
274        };
275
276        // Fill the background
277        for x in area.x..area.right() {
278            let mut cell = Cell::from_char(' ');
279            apply_style(&mut cell, style);
280            frame.buffer.set(x, area.y, cell);
281        }
282
283        let width = area.width as usize;
284        let left_width = self.items_fixed_width(&self.left);
285        let center_width = self.items_fixed_width(&self.center);
286        let right_width = self.items_fixed_width(&self.right);
287        let center_spacers = self.spacer_count(&self.center);
288
289        // Calculate positions
290        let left_x = area.x;
291        let right_x = area.right().saturating_sub(right_width as u16);
292        let available_center = width.saturating_sub(left_width).saturating_sub(right_width);
293        let center_target_width = if center_width > 0 && center_spacers > 0 {
294            available_center
295        } else {
296            center_width
297        };
298        let center_x = if center_width > 0 || center_spacers > 0 {
299            // Center the center items in the available space
300            let center_start =
301                left_width + available_center.saturating_sub(center_target_width) / 2;
302            area.x.saturating_add(center_start as u16)
303        } else {
304            area.x
305        };
306
307        let center_can_render = (center_width > 0 || center_spacers > 0)
308            && center_x + center_target_width as u16 <= right_x;
309        let left_max_x = if center_can_render { center_x } else { right_x };
310
311        // Render left items
312        if !self.left.is_empty() {
313            self.render_items(frame, &self.left, left_x, area.y, left_max_x, style);
314        }
315
316        // Render center items (if they fit)
317        if center_can_render {
318            self.render_items(frame, &self.center, center_x, area.y, right_x, style);
319        }
320
321        // Render right items
322        if !self.right.is_empty() && right_x >= area.x {
323            self.render_items(frame, &self.right, right_x, area.y, area.right(), style);
324        }
325    }
326
327    fn is_essential(&self) -> bool {
328        true // Status line should always render
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use ftui_render::buffer::Buffer;
336    use ftui_render::cell::PackedRgba;
337    use ftui_render::grapheme_pool::GraphemePool;
338
339    fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
340        (0..width)
341            .map(|x| {
342                buf.get(x, y)
343                    .and_then(|c| c.content.as_char())
344                    .unwrap_or(' ')
345            })
346            .collect::<String>()
347            .trim_end()
348            .to_string()
349    }
350
351    fn row_full(buf: &Buffer, y: u16, width: u16) -> String {
352        (0..width)
353            .map(|x| {
354                buf.get(x, y)
355                    .and_then(|c| c.content.as_char())
356                    .unwrap_or(' ')
357            })
358            .collect()
359    }
360
361    #[test]
362    fn empty_status_line() {
363        let status = StatusLine::new();
364        let area = Rect::new(0, 0, 20, 1);
365        let mut pool = GraphemePool::new();
366        let mut frame = Frame::new(20, 1, &mut pool);
367        status.render(area, &mut frame);
368
369        // Should just be spaces
370        let s = row_string(&frame.buffer, 0, 20);
371        assert!(s.is_empty() || s.chars().all(|c| c == ' '));
372    }
373
374    #[test]
375    fn left_only() {
376        let status = StatusLine::new().left(StatusItem::text("[INSERT]"));
377        let area = Rect::new(0, 0, 20, 1);
378        let mut pool = GraphemePool::new();
379        let mut frame = Frame::new(20, 1, &mut pool);
380        status.render(area, &mut frame);
381
382        let s = row_string(&frame.buffer, 0, 20);
383        assert!(s.starts_with("[INSERT]"), "Got: '{s}'");
384    }
385
386    #[test]
387    fn right_only() {
388        let status = StatusLine::new().right(StatusItem::text("Ln 42"));
389        let area = Rect::new(0, 0, 20, 1);
390        let mut pool = GraphemePool::new();
391        let mut frame = Frame::new(20, 1, &mut pool);
392        status.render(area, &mut frame);
393
394        let s = row_string(&frame.buffer, 0, 20);
395        assert!(s.ends_with("Ln 42"), "Got: '{s}'");
396    }
397
398    #[test]
399    fn center_only() {
400        let status = StatusLine::new().center(StatusItem::text("file.rs"));
401        let area = Rect::new(0, 0, 20, 1);
402        let mut pool = GraphemePool::new();
403        let mut frame = Frame::new(20, 1, &mut pool);
404        status.render(area, &mut frame);
405
406        let s = row_string(&frame.buffer, 0, 20);
407        assert!(s.contains("file.rs"), "Got: '{s}'");
408        // Should be roughly centered
409        let pos = s.find("file.rs").unwrap();
410        assert!(pos > 2 && pos < 15, "Not centered, pos={pos}, got: '{s}'");
411    }
412
413    #[test]
414    fn all_three_regions() {
415        let status = StatusLine::new()
416            .left(StatusItem::text("L"))
417            .center(StatusItem::text("C"))
418            .right(StatusItem::text("R"));
419        let area = Rect::new(0, 0, 20, 1);
420        let mut pool = GraphemePool::new();
421        let mut frame = Frame::new(20, 1, &mut pool);
422        status.render(area, &mut frame);
423
424        let s = row_string(&frame.buffer, 0, 20);
425        assert!(s.starts_with("L"), "Got: '{s}'");
426        assert!(s.ends_with("R"), "Got: '{s}'");
427        assert!(s.contains("C"), "Got: '{s}'");
428    }
429
430    #[test]
431    fn key_hint() {
432        let status = StatusLine::new().left(StatusItem::key_hint("^C", "Quit"));
433        let area = Rect::new(0, 0, 20, 1);
434        let mut pool = GraphemePool::new();
435        let mut frame = Frame::new(20, 1, &mut pool);
436        status.render(area, &mut frame);
437
438        let s = row_string(&frame.buffer, 0, 20);
439        assert!(s.contains("^C Quit"), "Got: '{s}'");
440    }
441
442    #[test]
443    fn progress() {
444        let status = StatusLine::new().left(StatusItem::progress(50, 100));
445        let area = Rect::new(0, 0, 20, 1);
446        let mut pool = GraphemePool::new();
447        let mut frame = Frame::new(20, 1, &mut pool);
448        status.render(area, &mut frame);
449
450        let s = row_string(&frame.buffer, 0, 20);
451        assert!(s.contains("50%"), "Got: '{s}'");
452    }
453
454    #[test]
455    fn multiple_items_left() {
456        let status = StatusLine::new()
457            .left(StatusItem::text("A"))
458            .left(StatusItem::text("B"))
459            .left(StatusItem::text("C"));
460        let area = Rect::new(0, 0, 20, 1);
461        let mut pool = GraphemePool::new();
462        let mut frame = Frame::new(20, 1, &mut pool);
463        status.render(area, &mut frame);
464
465        let s = row_string(&frame.buffer, 0, 20);
466        assert!(s.starts_with("A B C"), "Got: '{s}'");
467    }
468
469    #[test]
470    fn custom_separator() {
471        let status = StatusLine::new()
472            .separator(" | ")
473            .left(StatusItem::text("A"))
474            .left(StatusItem::text("B"));
475        let area = Rect::new(0, 0, 20, 1);
476        let mut pool = GraphemePool::new();
477        let mut frame = Frame::new(20, 1, &mut pool);
478        status.render(area, &mut frame);
479
480        let s = row_string(&frame.buffer, 0, 20);
481        assert!(s.contains("A | B"), "Got: '{s}'");
482    }
483
484    #[test]
485    fn spacer_expands_and_skips_separators() {
486        let status = StatusLine::new()
487            .separator(" | ")
488            .left(StatusItem::text("L"))
489            .left(StatusItem::spacer())
490            .left(StatusItem::text("R"));
491        let area = Rect::new(0, 0, 10, 1);
492        let mut pool = GraphemePool::new();
493        let mut frame = Frame::new(10, 1, &mut pool);
494        status.render(area, &mut frame);
495
496        let row = row_full(&frame.buffer, 0, 10);
497        let chars: Vec<char> = row.chars().collect();
498        assert_eq!(chars[0], 'L');
499        assert_eq!(chars[9], 'R');
500        assert!(
501            !row.contains('|'),
502            "Spacer should skip separators, got: '{row}'"
503        );
504    }
505
506    #[test]
507    fn style_applied() {
508        let fg = PackedRgba::rgb(255, 0, 0);
509        let status = StatusLine::new()
510            .style(Style::new().fg(fg))
511            .left(StatusItem::text("X"));
512        let area = Rect::new(0, 0, 10, 1);
513        let mut pool = GraphemePool::new();
514        let mut frame = Frame::new(10, 1, &mut pool);
515        status.render(area, &mut frame);
516
517        assert_eq!(frame.buffer.get(0, 0).unwrap().fg, fg);
518    }
519
520    #[test]
521    fn is_essential() {
522        let status = StatusLine::new();
523        assert!(status.is_essential());
524    }
525
526    #[test]
527    fn zero_area_no_panic() {
528        let status = StatusLine::new().left(StatusItem::text("Test"));
529        let area = Rect::new(0, 0, 0, 0);
530        let mut pool = GraphemePool::new();
531        let mut frame = Frame::new(1, 1, &mut pool);
532        status.render(area, &mut frame);
533        // Should not panic
534    }
535
536    #[test]
537    fn truncation_when_too_narrow() {
538        let status = StatusLine::new()
539            .left(StatusItem::text("VERYLONGTEXT"))
540            .right(StatusItem::text("R"));
541        let area = Rect::new(0, 0, 10, 1);
542        let mut pool = GraphemePool::new();
543        let mut frame = Frame::new(10, 1, &mut pool);
544        status.render(area, &mut frame);
545
546        // Should render what fits without panicking
547        let s = row_string(&frame.buffer, 0, 10);
548        assert!(!s.is_empty(), "Got empty string");
549    }
550}