Skip to main content

zlayer_tui/widgets/
status_list.rs

1//! Generic status list widget with vertical and horizontal orientations.
2//!
3//! Unifies the builder's `InstructionList` (vertical, with scroll and
4//! `[cached]` tags) and the CLI's step indicator (horizontal, with
5//! `" -> "` separators) into a single generic component.
6
7use ratatui::prelude::*;
8
9use crate::icons;
10use crate::palette::color;
11
12// ---------------------------------------------------------------------------
13// StatusItem trait
14// ---------------------------------------------------------------------------
15
16/// Trait for items that can be displayed inside a [`StatusList`].
17pub trait StatusItem {
18    /// The label text for this item.
19    fn label(&self) -> &str;
20
21    /// The current status of this item.
22    fn status(&self) -> ItemStatus;
23
24    /// An optional styled tag rendered after the label (e.g., `[cached]`).
25    fn tag(&self) -> Option<(&str, Style)> {
26        None
27    }
28}
29
30/// Lifecycle status of a single list item.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ItemStatus {
33    Pending,
34    Active,
35    Complete,
36    Failed,
37}
38
39/// Layout direction for the status list.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Orientation {
42    Vertical,
43    Horizontal,
44}
45
46// ---------------------------------------------------------------------------
47// StatusList widget
48// ---------------------------------------------------------------------------
49
50/// A list of [`StatusItem`]s rendered either vertically (scrollable, with
51/// a count indicator) or horizontally (with `" -> "` separators).
52pub struct StatusList<'a, T: StatusItem> {
53    /// Items to display.
54    pub items: &'a [T],
55    /// Index of the current / active item (used for centering in vertical
56    /// mode, and for styling in both modes).
57    pub current: usize,
58    /// Layout direction.
59    pub orientation: Orientation,
60}
61
62impl<'a, T: StatusItem> StatusList<'a, T> {
63    /// Create a new vertical status list.
64    pub fn new(items: &'a [T], current: usize) -> Self {
65        Self {
66            items,
67            current,
68            orientation: Orientation::Vertical,
69        }
70    }
71
72    /// Create a new horizontal status list.
73    pub fn horizontal(items: &'a [T], current: usize) -> Self {
74        Self {
75            items,
76            current,
77            orientation: Orientation::Horizontal,
78        }
79    }
80}
81
82impl<T: StatusItem> Widget for StatusList<'_, T> {
83    fn render(self, area: Rect, buf: &mut Buffer) {
84        if area.height == 0 || area.width == 0 || self.items.is_empty() {
85            return;
86        }
87
88        match self.orientation {
89            Orientation::Vertical => render_vertical(self.items, self.current, area, buf),
90            Orientation::Horizontal => render_horizontal(self.items, self.current, area, buf),
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Vertical rendering
97// ---------------------------------------------------------------------------
98
99#[allow(clippy::cast_possible_truncation)]
100fn render_vertical<T: StatusItem>(items: &[T], current: usize, area: Rect, buf: &mut Buffer) {
101    let visible_count = area.height as usize;
102    let total = items.len();
103
104    // Calculate window centered on `current`
105    let (start, end) = if total <= visible_count {
106        (0, total)
107    } else {
108        let half = visible_count / 2;
109        let start = if current < half {
110            0
111        } else if current + half >= total {
112            total.saturating_sub(visible_count)
113        } else {
114            current.saturating_sub(half)
115        };
116        let end = (start + visible_count).min(total);
117        (start, end)
118    };
119
120    for (display_idx, idx) in (start..end).enumerate() {
121        if display_idx >= visible_count {
122            break;
123        }
124
125        let item = &items[idx];
126        let y = area.y + display_idx as u16;
127        let status = item.status();
128
129        // Status icon via crate::icons
130        let (icon_ch, icon_style) = status_to_icon(status);
131        buf.set_string(area.x, y, icon_ch.to_string(), icon_style);
132
133        // Label text
134        let text_style = status_text_style(status);
135        let available_width = area.width.saturating_sub(2) as usize; // icon + space
136        let label = item.label();
137        let display_label = if label.len() > available_width {
138            if available_width >= 4 {
139                format!("{}...", &label[..available_width.saturating_sub(3)])
140            } else {
141                label[..available_width].to_string()
142            }
143        } else {
144            label.to_string()
145        };
146
147        buf.set_string(area.x + 2, y, &display_label, text_style);
148
149        // Optional tag after the label
150        if let Some((tag_text, tag_style)) = item.tag() {
151            let label_end = area.x + 2 + display_label.len() as u16;
152            let tag_x = label_end.min(area.x + area.width.saturating_sub(tag_text.len() as u16));
153
154            if tag_x + tag_text.len() as u16 <= area.x + area.width {
155                buf.set_string(tag_x, y, tag_text, tag_style);
156            }
157        }
158    }
159
160    // Scroll indicator when items overflow
161    if total > visible_count {
162        let indicator = format!("({}/{})", end.min(total), total);
163        let x = area.x + area.width.saturating_sub(indicator.len() as u16);
164        if x >= area.x {
165            buf.set_string(x, area.y, &indicator, Style::default().fg(color::INACTIVE));
166        }
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Horizontal rendering
172// ---------------------------------------------------------------------------
173
174#[allow(clippy::cast_possible_truncation)]
175fn render_horizontal<T: StatusItem>(items: &[T], current: usize, area: Rect, buf: &mut Buffer) {
176    let separator = " -> ";
177    let mut x = area.x;
178
179    for (i, item) in items.iter().enumerate() {
180        if x >= area.x + area.width {
181            break;
182        }
183
184        let status = item.status();
185
186        // Icon
187        let (icon_ch, icon_style) = horizontal_icon(status, i, current);
188        let icon_str = format!("{icon_ch} ");
189        let icon_len = icon_str.len() as u16;
190
191        if x + icon_len > area.x + area.width {
192            break;
193        }
194        buf.set_string(x, area.y, &icon_str, icon_style);
195        x += icon_len;
196
197        // Label
198        let label = item.label();
199        let label_style = horizontal_label_style(status, i, current);
200        let remaining = (area.x + area.width).saturating_sub(x) as usize;
201        let display_label = if label.len() > remaining {
202            if remaining >= 4 {
203                format!("{}...", &label[..remaining.saturating_sub(3)])
204            } else {
205                label[..remaining].to_string()
206            }
207        } else {
208            label.to_string()
209        };
210
211        buf.set_string(x, area.y, &display_label, label_style);
212        x += display_label.len() as u16;
213
214        // Separator between items (not after the last one)
215        if i < items.len() - 1 {
216            let sep_len = separator.len() as u16;
217            if x + sep_len <= area.x + area.width {
218                buf.set_string(x, area.y, separator, Style::default().fg(color::INACTIVE));
219                x += sep_len;
220            }
221        }
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Helpers
227// ---------------------------------------------------------------------------
228
229/// Map an [`ItemStatus`] to an icon + style using [`crate::icons::status_icon`].
230fn status_to_icon(status: ItemStatus) -> (char, Style) {
231    match status {
232        ItemStatus::Pending => icons::status_icon(false, false, false),
233        ItemStatus::Active => icons::status_icon(false, true, false),
234        ItemStatus::Complete => icons::status_icon(true, false, false),
235        ItemStatus::Failed => icons::status_icon(false, false, true),
236    }
237}
238
239/// Text style for a label in vertical mode based on its status.
240fn status_text_style(status: ItemStatus) -> Style {
241    match status {
242        ItemStatus::Pending => Style::default().fg(color::INACTIVE),
243        ItemStatus::Active => Style::default()
244            .fg(color::WARNING)
245            .add_modifier(Modifier::BOLD),
246        ItemStatus::Complete => Style::default().fg(color::TEXT),
247        ItemStatus::Failed => Style::default().fg(color::ERROR),
248    }
249}
250
251/// Icon for horizontal orientation, where completed items get green, active
252/// gets cyan+bold, and pending gets dark gray.
253fn horizontal_icon(status: ItemStatus, idx: usize, current: usize) -> (char, Style) {
254    if idx < current || status == ItemStatus::Complete {
255        (icons::COMPLETE, Style::default().fg(color::SUCCESS))
256    } else if idx == current || status == ItemStatus::Active {
257        (
258            icons::RUNNING,
259            Style::default()
260                .fg(color::ACCENT)
261                .add_modifier(Modifier::BOLD),
262        )
263    } else {
264        (icons::PENDING, Style::default().fg(color::INACTIVE))
265    }
266}
267
268/// Label style for horizontal orientation.
269fn horizontal_label_style(status: ItemStatus, idx: usize, current: usize) -> Style {
270    if idx < current || status == ItemStatus::Complete {
271        Style::default().fg(color::SUCCESS)
272    } else if idx == current || status == ItemStatus::Active {
273        Style::default()
274            .fg(color::ACCENT)
275            .add_modifier(Modifier::BOLD)
276    } else {
277        Style::default().fg(color::INACTIVE)
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Tests
283// ---------------------------------------------------------------------------
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // A simple test item
290    #[derive(Debug)]
291    struct Step {
292        label: String,
293        status: ItemStatus,
294        tag: Option<(String, Style)>,
295    }
296
297    impl Step {
298        fn new(label: &str, status: ItemStatus) -> Self {
299            Self {
300                label: label.to_string(),
301                status,
302                tag: None,
303            }
304        }
305
306        fn with_tag(mut self, tag: &str, style: Style) -> Self {
307            self.tag = Some((tag.to_string(), style));
308            self
309        }
310    }
311
312    impl StatusItem for Step {
313        fn label(&self) -> &str {
314            &self.label
315        }
316
317        fn status(&self) -> ItemStatus {
318            self.status
319        }
320
321        fn tag(&self) -> Option<(&str, Style)> {
322            self.tag.as_ref().map(|(s, st)| (s.as_str(), *st))
323        }
324    }
325
326    fn create_buffer(width: u16, height: u16) -> Buffer {
327        Buffer::empty(Rect::new(0, 0, width, height))
328    }
329
330    fn buffer_text(buf: &Buffer) -> String {
331        buf.content()
332            .iter()
333            .map(ratatui::buffer::Cell::symbol)
334            .collect()
335    }
336
337    // -- Empty state --
338
339    #[test]
340    fn empty_items_does_not_panic() {
341        let mut buf = create_buffer(40, 5);
342        let area = Rect::new(0, 0, 40, 5);
343
344        let items: Vec<Step> = vec![];
345        let list = StatusList::new(&items, 0);
346        list.render(area, &mut buf);
347    }
348
349    #[test]
350    fn empty_horizontal_does_not_panic() {
351        let mut buf = create_buffer(80, 1);
352        let area = Rect::new(0, 0, 80, 1);
353
354        let items: Vec<Step> = vec![];
355        let list = StatusList::horizontal(&items, 0);
356        list.render(area, &mut buf);
357    }
358
359    // -- Vertical mode --
360
361    #[test]
362    fn vertical_renders_labels() {
363        let mut buf = create_buffer(60, 5);
364        let area = Rect::new(0, 0, 60, 5);
365
366        let items = vec![
367            Step::new("WORKDIR /app", ItemStatus::Complete),
368            Step::new("RUN npm ci", ItemStatus::Active),
369            Step::new("COPY . .", ItemStatus::Pending),
370        ];
371
372        let list = StatusList::new(&items, 1);
373        list.render(area, &mut buf);
374
375        let text = buffer_text(&buf);
376        assert!(text.contains("WORKDIR"));
377        assert!(text.contains("RUN npm ci"));
378        assert!(text.contains("COPY"));
379    }
380
381    #[test]
382    fn vertical_renders_icons() {
383        let mut buf = create_buffer(60, 5);
384        let area = Rect::new(0, 0, 60, 5);
385
386        let items = vec![
387            Step::new("done", ItemStatus::Complete),
388            Step::new("active", ItemStatus::Active),
389            Step::new("waiting", ItemStatus::Pending),
390            Step::new("failed", ItemStatus::Failed),
391        ];
392
393        let list = StatusList::new(&items, 1);
394        list.render(area, &mut buf);
395
396        let text = buffer_text(&buf);
397        // Check that icons from crate::icons are present
398        assert!(text.contains(icons::COMPLETE));
399        assert!(text.contains(icons::RUNNING));
400        assert!(text.contains(icons::PENDING));
401        assert!(text.contains(icons::FAILED));
402    }
403
404    #[test]
405    fn vertical_tag_rendered() {
406        let mut buf = create_buffer(60, 3);
407        let area = Rect::new(0, 0, 60, 3);
408
409        let items = vec![Step::new("COPY package.json ./", ItemStatus::Complete)
410            .with_tag(" [cached]", Style::default().fg(Color::Cyan))];
411
412        let list = StatusList::new(&items, 0);
413        list.render(area, &mut buf);
414
415        let text = buffer_text(&buf);
416        assert!(text.contains("[cached]"));
417    }
418
419    #[test]
420    fn vertical_scroll_indicator() {
421        let mut buf = create_buffer(40, 3);
422        let area = Rect::new(0, 0, 40, 3);
423
424        let items: Vec<Step> = (0..10)
425            .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
426            .collect();
427
428        let list = StatusList::new(&items, 5);
429        list.render(area, &mut buf);
430
431        let text = buffer_text(&buf);
432        // Should show something like "(8/10)"
433        assert!(text.contains('/'));
434        assert!(text.contains('('));
435    }
436
437    #[test]
438    fn vertical_no_scroll_when_all_visible() {
439        let mut buf = create_buffer(60, 10);
440        let area = Rect::new(0, 0, 60, 10);
441
442        let items = vec![
443            Step::new("one", ItemStatus::Complete),
444            Step::new("two", ItemStatus::Active),
445        ];
446
447        let list = StatusList::new(&items, 1);
448        list.render(area, &mut buf);
449
450        let text = buffer_text(&buf);
451        // No scroll indicator because all items fit
452        assert!(!text.contains("(/"));
453    }
454
455    #[test]
456    fn vertical_truncates_long_labels() {
457        let mut buf = create_buffer(15, 2);
458        let area = Rect::new(0, 0, 15, 2);
459
460        let items = vec![Step::new(
461            "A very long instruction label that exceeds width",
462            ItemStatus::Active,
463        )];
464
465        let list = StatusList::new(&items, 0);
466        list.render(area, &mut buf);
467
468        let text = buffer_text(&buf);
469        assert!(text.contains("..."));
470    }
471
472    #[test]
473    fn vertical_centering_at_start() {
474        let mut buf = create_buffer(40, 3);
475        let area = Rect::new(0, 0, 40, 3);
476
477        let items: Vec<Step> = (0..10)
478            .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
479            .collect();
480
481        // Current = 0: window should start at 0
482        let list = StatusList::new(&items, 0);
483        list.render(area, &mut buf);
484
485        let text = buffer_text(&buf);
486        assert!(text.contains("Step 0"));
487    }
488
489    #[test]
490    fn vertical_centering_at_end() {
491        let mut buf = create_buffer(40, 3);
492        let area = Rect::new(0, 0, 40, 3);
493
494        let items: Vec<Step> = (0..10)
495            .map(|i| Step::new(&format!("Step {i}"), ItemStatus::Pending))
496            .collect();
497
498        // Current = 9 (last): window should show the last 3 items
499        let list = StatusList::new(&items, 9);
500        list.render(area, &mut buf);
501
502        let text = buffer_text(&buf);
503        assert!(text.contains("Step 9"));
504    }
505
506    // -- Horizontal mode --
507
508    #[test]
509    fn horizontal_renders_items_with_separators() {
510        let mut buf = create_buffer(80, 1);
511        let area = Rect::new(0, 0, 80, 1);
512
513        let items = vec![
514            Step::new("Source", ItemStatus::Complete),
515            Step::new("Configure", ItemStatus::Active),
516            Step::new("Build", ItemStatus::Pending),
517        ];
518
519        let list = StatusList::horizontal(&items, 1);
520        list.render(area, &mut buf);
521
522        let text = buffer_text(&buf);
523        assert!(text.contains("Source"));
524        assert!(text.contains("Configure"));
525        assert!(text.contains("Build"));
526        assert!(text.contains("->"));
527    }
528
529    #[test]
530    fn horizontal_completed_items_use_checkmark() {
531        let mut buf = create_buffer(80, 1);
532        let area = Rect::new(0, 0, 80, 1);
533
534        let items = vec![
535            Step::new("Done", ItemStatus::Complete),
536            Step::new("Active", ItemStatus::Active),
537            Step::new("Waiting", ItemStatus::Pending),
538        ];
539
540        let list = StatusList::horizontal(&items, 1);
541        list.render(area, &mut buf);
542
543        let text = buffer_text(&buf);
544        assert!(text.contains(icons::COMPLETE));
545        assert!(text.contains(icons::RUNNING));
546        assert!(text.contains(icons::PENDING));
547    }
548
549    #[test]
550    fn horizontal_narrow_area_truncates() {
551        let mut buf = create_buffer(20, 1);
552        let area = Rect::new(0, 0, 20, 1);
553
554        let items = vec![
555            Step::new("VeryLongStepName", ItemStatus::Complete),
556            Step::new("AnotherLongOne", ItemStatus::Active),
557        ];
558
559        let list = StatusList::horizontal(&items, 1);
560        list.render(area, &mut buf);
561
562        // Should not panic, even if text is cut off
563    }
564
565    // -- Orientation constructors --
566
567    #[test]
568    fn new_creates_vertical() {
569        let items = vec![Step::new("a", ItemStatus::Pending)];
570        let list = StatusList::new(&items, 0);
571        assert_eq!(list.orientation, Orientation::Vertical);
572    }
573
574    #[test]
575    fn horizontal_constructor() {
576        let items = vec![Step::new("a", ItemStatus::Pending)];
577        let list = StatusList::horizontal(&items, 0);
578        assert_eq!(list.orientation, Orientation::Horizontal);
579    }
580
581    // -- Edge cases --
582
583    #[test]
584    fn zero_height_does_not_panic() {
585        let mut buf = create_buffer(40, 0);
586        let area = Rect::new(0, 0, 40, 0);
587
588        let items = vec![Step::new("x", ItemStatus::Active)];
589        let list = StatusList::new(&items, 0);
590        list.render(area, &mut buf);
591    }
592
593    #[test]
594    fn zero_width_does_not_panic() {
595        let mut buf = create_buffer(0, 5);
596        let area = Rect::new(0, 0, 0, 5);
597
598        let items = vec![Step::new("x", ItemStatus::Active)];
599        let list = StatusList::new(&items, 0);
600        list.render(area, &mut buf);
601    }
602
603    #[test]
604    fn current_out_of_bounds_does_not_panic() {
605        let mut buf = create_buffer(40, 5);
606        let area = Rect::new(0, 0, 40, 5);
607
608        let items = vec![Step::new("only", ItemStatus::Active)];
609        // current = 99 is way past the end
610        let list = StatusList::new(&items, 99);
611        list.render(area, &mut buf);
612
613        let text = buffer_text(&buf);
614        assert!(text.contains("only"));
615    }
616
617    #[test]
618    fn item_status_equality() {
619        assert_eq!(ItemStatus::Pending, ItemStatus::Pending);
620        assert_ne!(ItemStatus::Pending, ItemStatus::Active);
621        assert_ne!(ItemStatus::Complete, ItemStatus::Failed);
622    }
623}