Skip to main content

zlayer_tui/widgets/
scrollable_pane.rs

1//! Generic scrollable pane widget with pluggable entry types.
2//!
3//! Unifies the builder's `OutputLog` and deploy's `LogPane` into a single
4//! reusable component with identical scroll offset math, line truncation,
5//! empty state placeholder, and scroll percentage indicator.
6
7use ratatui::prelude::*;
8use ratatui::widgets::{Block, Borders, Paragraph};
9
10use crate::palette::color;
11
12// ---------------------------------------------------------------------------
13// PaneEntry trait
14// ---------------------------------------------------------------------------
15
16/// Trait for items that can be displayed inside a [`ScrollablePane`].
17pub trait PaneEntry {
18    /// The main text content of this entry.
19    fn display_text(&self) -> &str;
20
21    /// An optional styled prefix rendered before the display text.
22    ///
23    /// Return `Some(("prefix ", style))` to prepend a label such as `[INFO]`.
24    fn prefix(&self) -> Option<(&str, Style)> {
25        None
26    }
27
28    /// Style applied to the display text returned by [`display_text`](PaneEntry::display_text).
29    fn text_style(&self) -> Style {
30        Style::default().fg(color::TEXT)
31    }
32}
33
34// ---------------------------------------------------------------------------
35// ScrollablePane widget
36// ---------------------------------------------------------------------------
37
38/// A bordered, scrollable list of [`PaneEntry`] items with automatic
39/// truncation and a scroll percentage badge.
40pub struct ScrollablePane<'a, T: PaneEntry> {
41    /// Slice of entries to display.
42    pub entries: &'a [T],
43    /// Line offset from the top (0 = show from the first entry).
44    pub scroll_offset: usize,
45    /// Optional title rendered in the top border.
46    pub title: Option<&'a str>,
47    /// Style applied to the block border.
48    pub border_style: Style,
49    /// Text shown when `entries` is empty.
50    pub empty_text: &'a str,
51}
52
53impl<'a, T: PaneEntry> ScrollablePane<'a, T> {
54    /// Create a new pane with sensible defaults.
55    pub fn new(entries: &'a [T], scroll_offset: usize) -> Self {
56        Self {
57            entries,
58            scroll_offset,
59            title: None,
60            border_style: Style::default().fg(color::INACTIVE),
61            empty_text: "No output yet",
62        }
63    }
64
65    /// Set the block title.
66    pub fn with_title(mut self, title: &'a str) -> Self {
67        self.title = Some(title);
68        self
69    }
70
71    /// Set the border style.
72    pub fn with_border_style(mut self, style: Style) -> Self {
73        self.border_style = style;
74        self
75    }
76
77    /// Set the placeholder text shown when there are no entries.
78    pub fn with_empty_text(mut self, text: &'a str) -> Self {
79        self.empty_text = text;
80        self
81    }
82}
83
84impl<T: PaneEntry> Widget for ScrollablePane<'_, T> {
85    fn render(self, area: Rect, buf: &mut Buffer) {
86        if area.height == 0 || area.width == 0 {
87            return;
88        }
89
90        // Block with optional title
91        let mut block = Block::default()
92            .borders(Borders::ALL)
93            .border_style(self.border_style);
94
95        if let Some(title) = self.title {
96            block = block.title(format!(" {} ", title));
97        }
98
99        let inner = block.inner(area);
100        block.render(area, buf);
101
102        if inner.height == 0 || inner.width == 0 {
103            return;
104        }
105
106        // Empty state
107        if self.entries.is_empty() {
108            Paragraph::new(self.empty_text)
109                .style(
110                    Style::default()
111                        .fg(color::INACTIVE)
112                        .add_modifier(Modifier::ITALIC),
113                )
114                .render(inner, buf);
115            return;
116        }
117
118        let visible_count = inner.height as usize;
119        let total = self.entries.len();
120
121        // Scroll math: clamp so we never start past the last screenful
122        let start = self.scroll_offset.min(total.saturating_sub(visible_count));
123        let end = (start + visible_count).min(total);
124
125        // Render visible entries
126        for (display_idx, idx) in (start..end).enumerate() {
127            if display_idx >= visible_count {
128                break;
129            }
130
131            let entry = &self.entries[idx];
132            let y = inner.y + display_idx as u16;
133            let mut x = inner.x;
134            let mut remaining_width = inner.width as usize;
135
136            // Optional prefix
137            if let Some((prefix_text, prefix_style)) = entry.prefix() {
138                let pw = prefix_text.len().min(remaining_width);
139                buf.set_string(x, y, &prefix_text[..pw], prefix_style);
140                x += pw as u16;
141                remaining_width = remaining_width.saturating_sub(pw);
142            }
143
144            // Display text with truncation
145            let text = entry.display_text();
146            if remaining_width == 0 {
147                continue;
148            }
149
150            let display = if text.len() > remaining_width {
151                // Need at least 4 chars for "X..." to make sense
152                if remaining_width >= 4 {
153                    format!("{}...", &text[..remaining_width.saturating_sub(3)])
154                } else {
155                    text[..remaining_width].to_string()
156                }
157            } else {
158                text.to_string()
159            };
160
161            buf.set_string(x, y, &display, entry.text_style());
162        }
163
164        // Scroll percentage badge (bottom-right of inner area)
165        if total > visible_count {
166            let percent = if total == 0 {
167                100
168            } else {
169                ((end as f64 / total as f64) * 100.0) as usize
170            };
171            let indicator = format!(" {}% ", percent);
172            let ind_len = indicator.len() as u16;
173
174            let badge_x = inner.x + inner.width.saturating_sub(ind_len + 1);
175            let badge_y = inner.y + inner.height.saturating_sub(1);
176
177            if badge_x >= inner.x && badge_y >= inner.y {
178                buf.set_string(
179                    badge_x,
180                    badge_y,
181                    &indicator,
182                    Style::default()
183                        .fg(color::SCROLL_BADGE_FG)
184                        .bg(color::SCROLL_BADGE_BG),
185                );
186            }
187        }
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Concrete PaneEntry: OutputLine (builder stdout/stderr)
193// ---------------------------------------------------------------------------
194
195/// A single line of build output, tagged as stdout or stderr.
196#[derive(Debug, Clone)]
197pub struct OutputLine {
198    /// The text content.
199    pub text: String,
200    /// Whether this line came from stderr.
201    pub is_stderr: bool,
202}
203
204impl PaneEntry for OutputLine {
205    fn display_text(&self) -> &str {
206        &self.text
207    }
208
209    fn text_style(&self) -> Style {
210        if self.is_stderr {
211            Style::default().fg(color::WARNING)
212        } else {
213            Style::default().fg(color::TEXT)
214        }
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Concrete PaneEntry: LogEntry (deploy log pane)
220// ---------------------------------------------------------------------------
221
222/// Severity level for a log entry.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum LogLevel {
225    Info,
226    Warn,
227    Error,
228}
229
230/// A single log message with a severity level.
231#[derive(Debug, Clone)]
232pub struct LogEntry {
233    /// Severity level.
234    pub level: LogLevel,
235    /// The log message text.
236    pub message: String,
237}
238
239impl PaneEntry for LogEntry {
240    fn display_text(&self) -> &str {
241        &self.message
242    }
243
244    fn prefix(&self) -> Option<(&str, Style)> {
245        match self.level {
246            LogLevel::Info => Some((
247                "[INFO] ",
248                Style::default()
249                    .fg(color::INACTIVE)
250                    .add_modifier(Modifier::BOLD),
251            )),
252            LogLevel::Warn => Some((
253                "[WARN] ",
254                Style::default()
255                    .fg(color::WARNING)
256                    .add_modifier(Modifier::BOLD),
257            )),
258            LogLevel::Error => Some((
259                "[ERROR] ",
260                Style::default()
261                    .fg(color::ERROR)
262                    .add_modifier(Modifier::BOLD),
263            )),
264        }
265    }
266
267    fn text_style(&self) -> Style {
268        match self.level {
269            LogLevel::Info => Style::default().fg(color::INACTIVE),
270            LogLevel::Warn => Style::default().fg(color::WARNING),
271            LogLevel::Error => Style::default().fg(color::ERROR),
272        }
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn create_buffer(width: u16, height: u16) -> Buffer {
285        Buffer::empty(Rect::new(0, 0, width, height))
286    }
287
288    fn buffer_text(buf: &Buffer) -> String {
289        buf.content().iter().map(|c| c.symbol()).collect()
290    }
291
292    // -- ScrollablePane with OutputLine --
293
294    #[test]
295    fn empty_entries_shows_placeholder() {
296        let mut buf = create_buffer(40, 5);
297        let area = Rect::new(0, 0, 40, 5);
298
299        let entries: Vec<OutputLine> = vec![];
300        let pane = ScrollablePane::new(&entries, 0);
301        pane.render(area, &mut buf);
302
303        let text = buffer_text(&buf);
304        assert!(text.contains("No output yet"));
305    }
306
307    #[test]
308    fn custom_empty_text() {
309        let mut buf = create_buffer(40, 5);
310        let area = Rect::new(0, 0, 40, 5);
311
312        let entries: Vec<OutputLine> = vec![];
313        let pane = ScrollablePane::new(&entries, 0).with_empty_text("Waiting for logs...");
314        pane.render(area, &mut buf);
315
316        let text = buffer_text(&buf);
317        assert!(text.contains("Waiting for logs..."));
318    }
319
320    #[test]
321    fn renders_output_lines() {
322        let mut buf = create_buffer(60, 6);
323        let area = Rect::new(0, 0, 60, 6);
324
325        let entries = vec![
326            OutputLine {
327                text: "stdout line one".to_string(),
328                is_stderr: false,
329            },
330            OutputLine {
331                text: "stderr warning".to_string(),
332                is_stderr: true,
333            },
334        ];
335
336        let pane = ScrollablePane::new(&entries, 0).with_title("Output");
337        pane.render(area, &mut buf);
338
339        let text = buffer_text(&buf);
340        assert!(text.contains("stdout line one"));
341        assert!(text.contains("stderr warning"));
342        assert!(text.contains("Output"));
343    }
344
345    #[test]
346    fn truncates_long_lines() {
347        // Inner width = 20 - 2 (borders) = 18
348        let mut buf = create_buffer(20, 4);
349        let area = Rect::new(0, 0, 20, 4);
350
351        let entries = vec![OutputLine {
352            text: "A very long line that should be truncated with ellipsis".to_string(),
353            is_stderr: false,
354        }];
355
356        let pane = ScrollablePane::new(&entries, 0);
357        pane.render(area, &mut buf);
358
359        let text = buffer_text(&buf);
360        assert!(text.contains("..."));
361    }
362
363    #[test]
364    fn scroll_offset_clamps_past_end() {
365        let mut buf = create_buffer(40, 5);
366        let area = Rect::new(0, 0, 40, 5);
367
368        let entries: Vec<OutputLine> = (0..3)
369            .map(|i| OutputLine {
370                text: format!("Line {}", i),
371                is_stderr: false,
372            })
373            .collect();
374
375        // Scroll offset way past the end -- should clamp gracefully
376        let pane = ScrollablePane::new(&entries, 100);
377        pane.render(area, &mut buf);
378
379        let text = buffer_text(&buf);
380        // Should still render something (the last visible entries)
381        assert!(text.contains("Line"));
382    }
383
384    #[test]
385    fn scroll_percentage_shown_when_scrollable() {
386        let mut buf = create_buffer(40, 5);
387        let area = Rect::new(0, 0, 40, 5);
388
389        // Inner height = 5 - 2 (borders) = 3 lines visible, 10 entries total
390        let entries: Vec<OutputLine> = (0..10)
391            .map(|i| OutputLine {
392                text: format!("Line {}", i),
393                is_stderr: false,
394            })
395            .collect();
396
397        let pane = ScrollablePane::new(&entries, 0);
398        pane.render(area, &mut buf);
399
400        let text = buffer_text(&buf);
401        assert!(text.contains('%'));
402    }
403
404    #[test]
405    fn no_scroll_badge_when_all_visible() {
406        let mut buf = create_buffer(40, 10);
407        let area = Rect::new(0, 0, 40, 10);
408
409        // Inner height = 10 - 2 = 8 visible, only 2 entries
410        let entries = vec![
411            OutputLine {
412                text: "one".to_string(),
413                is_stderr: false,
414            },
415            OutputLine {
416                text: "two".to_string(),
417                is_stderr: false,
418            },
419        ];
420
421        let pane = ScrollablePane::new(&entries, 0);
422        pane.render(area, &mut buf);
423
424        let text = buffer_text(&buf);
425        assert!(!text.contains('%'));
426    }
427
428    // -- ScrollablePane with LogEntry --
429
430    #[test]
431    fn log_entry_prefix_rendered() {
432        let mut buf = create_buffer(60, 6);
433        let area = Rect::new(0, 0, 60, 6);
434
435        let entries = vec![
436            LogEntry {
437                level: LogLevel::Info,
438                message: "Startup complete".to_string(),
439            },
440            LogEntry {
441                level: LogLevel::Warn,
442                message: "Overlay unavailable".to_string(),
443            },
444            LogEntry {
445                level: LogLevel::Error,
446                message: "Container crashed".to_string(),
447            },
448        ];
449
450        let pane = ScrollablePane::new(&entries, 0).with_title("Logs");
451        pane.render(area, &mut buf);
452
453        let text = buffer_text(&buf);
454        assert!(text.contains("[INFO]"));
455        assert!(text.contains("[WARN]"));
456        assert!(text.contains("[ERROR]"));
457        assert!(text.contains("Startup complete"));
458    }
459
460    #[test]
461    fn log_entry_scroll_shows_percentage() {
462        let mut buf = create_buffer(60, 5);
463        let area = Rect::new(0, 0, 60, 5);
464
465        let entries: Vec<LogEntry> = (0..20)
466            .map(|i| LogEntry {
467                level: LogLevel::Info,
468                message: format!("Log line {}", i),
469            })
470            .collect();
471
472        let pane = ScrollablePane::new(&entries, 5).with_title("Logs");
473        pane.render(area, &mut buf);
474
475        let text = buffer_text(&buf);
476        assert!(text.contains('%'));
477    }
478
479    #[test]
480    fn zero_height_does_not_panic() {
481        let mut buf = create_buffer(40, 0);
482        let area = Rect::new(0, 0, 40, 0);
483
484        let entries = vec![OutputLine {
485            text: "hello".to_string(),
486            is_stderr: false,
487        }];
488
489        let pane = ScrollablePane::new(&entries, 0);
490        pane.render(area, &mut buf);
491    }
492
493    #[test]
494    fn zero_width_does_not_panic() {
495        let mut buf = create_buffer(0, 5);
496        let area = Rect::new(0, 0, 0, 5);
497
498        let entries = vec![OutputLine {
499            text: "hello".to_string(),
500            is_stderr: false,
501        }];
502
503        let pane = ScrollablePane::new(&entries, 0);
504        pane.render(area, &mut buf);
505    }
506
507    #[test]
508    fn builder_methods_chain() {
509        let entries: Vec<OutputLine> = vec![];
510        let pane = ScrollablePane::new(&entries, 0)
511            .with_title("Test")
512            .with_border_style(Style::default().fg(Color::Blue))
513            .with_empty_text("Nothing here");
514
515        assert_eq!(pane.title, Some("Test"));
516        assert_eq!(pane.empty_text, "Nothing here");
517    }
518
519    #[test]
520    fn output_line_stderr_vs_stdout_styles() {
521        let stdout = OutputLine {
522            text: "ok".to_string(),
523            is_stderr: false,
524        };
525        let stderr = OutputLine {
526            text: "err".to_string(),
527            is_stderr: true,
528        };
529
530        assert_eq!(stdout.text_style().fg, Some(color::TEXT));
531        assert_eq!(stderr.text_style().fg, Some(color::WARNING));
532    }
533
534    #[test]
535    fn log_level_styles_differ() {
536        let info = LogEntry {
537            level: LogLevel::Info,
538            message: String::new(),
539        };
540        let warn = LogEntry {
541            level: LogLevel::Warn,
542            message: String::new(),
543        };
544        let error = LogEntry {
545            level: LogLevel::Error,
546            message: String::new(),
547        };
548
549        assert_eq!(info.text_style().fg, Some(color::INACTIVE));
550        assert_eq!(warn.text_style().fg, Some(color::WARNING));
551        assert_eq!(error.text_style().fg, Some(color::ERROR));
552    }
553}