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