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().iter().map(|c| c.symbol()).collect()
298    }
299
300    // -- ScrollablePane with OutputLine --
301
302    #[test]
303    fn empty_entries_shows_placeholder() {
304        let mut buf = create_buffer(40, 5);
305        let area = Rect::new(0, 0, 40, 5);
306
307        let entries: Vec<OutputLine> = vec![];
308        let pane = ScrollablePane::new(&entries, 0);
309        pane.render(area, &mut buf);
310
311        let text = buffer_text(&buf);
312        assert!(text.contains("No output yet"));
313    }
314
315    #[test]
316    fn custom_empty_text() {
317        let mut buf = create_buffer(40, 5);
318        let area = Rect::new(0, 0, 40, 5);
319
320        let entries: Vec<OutputLine> = vec![];
321        let pane = ScrollablePane::new(&entries, 0).with_empty_text("Waiting for logs...");
322        pane.render(area, &mut buf);
323
324        let text = buffer_text(&buf);
325        assert!(text.contains("Waiting for logs..."));
326    }
327
328    #[test]
329    fn renders_output_lines() {
330        let mut buf = create_buffer(60, 6);
331        let area = Rect::new(0, 0, 60, 6);
332
333        let entries = vec![
334            OutputLine {
335                text: "stdout line one".to_string(),
336                is_stderr: false,
337            },
338            OutputLine {
339                text: "stderr warning".to_string(),
340                is_stderr: true,
341            },
342        ];
343
344        let pane = ScrollablePane::new(&entries, 0).with_title("Output");
345        pane.render(area, &mut buf);
346
347        let text = buffer_text(&buf);
348        assert!(text.contains("stdout line one"));
349        assert!(text.contains("stderr warning"));
350        assert!(text.contains("Output"));
351    }
352
353    #[test]
354    fn truncates_long_lines() {
355        // Inner width = 20 - 2 (borders) = 18
356        let mut buf = create_buffer(20, 4);
357        let area = Rect::new(0, 0, 20, 4);
358
359        let entries = vec![OutputLine {
360            text: "A very long line that should be truncated with ellipsis".to_string(),
361            is_stderr: false,
362        }];
363
364        let pane = ScrollablePane::new(&entries, 0);
365        pane.render(area, &mut buf);
366
367        let text = buffer_text(&buf);
368        assert!(text.contains("..."));
369    }
370
371    #[test]
372    fn scroll_offset_clamps_past_end() {
373        let mut buf = create_buffer(40, 5);
374        let area = Rect::new(0, 0, 40, 5);
375
376        let entries: Vec<OutputLine> = (0..3)
377            .map(|i| OutputLine {
378                text: format!("Line {}", i),
379                is_stderr: false,
380            })
381            .collect();
382
383        // Scroll offset way past the end -- should clamp gracefully
384        let pane = ScrollablePane::new(&entries, 100);
385        pane.render(area, &mut buf);
386
387        let text = buffer_text(&buf);
388        // Should still render something (the last visible entries)
389        assert!(text.contains("Line"));
390    }
391
392    #[test]
393    fn scroll_percentage_shown_when_scrollable() {
394        let mut buf = create_buffer(40, 5);
395        let area = Rect::new(0, 0, 40, 5);
396
397        // Inner height = 5 - 2 (borders) = 3 lines visible, 10 entries total
398        let entries: Vec<OutputLine> = (0..10)
399            .map(|i| OutputLine {
400                text: format!("Line {}", i),
401                is_stderr: false,
402            })
403            .collect();
404
405        let pane = ScrollablePane::new(&entries, 0);
406        pane.render(area, &mut buf);
407
408        let text = buffer_text(&buf);
409        assert!(text.contains('%'));
410    }
411
412    #[test]
413    fn no_scroll_badge_when_all_visible() {
414        let mut buf = create_buffer(40, 10);
415        let area = Rect::new(0, 0, 40, 10);
416
417        // Inner height = 10 - 2 = 8 visible, only 2 entries
418        let entries = vec![
419            OutputLine {
420                text: "one".to_string(),
421                is_stderr: false,
422            },
423            OutputLine {
424                text: "two".to_string(),
425                is_stderr: false,
426            },
427        ];
428
429        let pane = ScrollablePane::new(&entries, 0);
430        pane.render(area, &mut buf);
431
432        let text = buffer_text(&buf);
433        assert!(!text.contains('%'));
434    }
435
436    // -- ScrollablePane with LogEntry --
437
438    #[test]
439    fn log_entry_prefix_rendered() {
440        let mut buf = create_buffer(60, 6);
441        let area = Rect::new(0, 0, 60, 6);
442
443        let entries = vec![
444            LogEntry {
445                level: LogLevel::Info,
446                message: "Startup complete".to_string(),
447            },
448            LogEntry {
449                level: LogLevel::Warn,
450                message: "Overlay unavailable".to_string(),
451            },
452            LogEntry {
453                level: LogLevel::Error,
454                message: "Container crashed".to_string(),
455            },
456        ];
457
458        let pane = ScrollablePane::new(&entries, 0).with_title("Logs");
459        pane.render(area, &mut buf);
460
461        let text = buffer_text(&buf);
462        assert!(text.contains("[INFO]"));
463        assert!(text.contains("[WARN]"));
464        assert!(text.contains("[ERROR]"));
465        assert!(text.contains("Startup complete"));
466    }
467
468    #[test]
469    fn log_entry_scroll_shows_percentage() {
470        let mut buf = create_buffer(60, 5);
471        let area = Rect::new(0, 0, 60, 5);
472
473        let entries: Vec<LogEntry> = (0..20)
474            .map(|i| LogEntry {
475                level: LogLevel::Info,
476                message: format!("Log line {}", i),
477            })
478            .collect();
479
480        let pane = ScrollablePane::new(&entries, 5).with_title("Logs");
481        pane.render(area, &mut buf);
482
483        let text = buffer_text(&buf);
484        assert!(text.contains('%'));
485    }
486
487    #[test]
488    fn zero_height_does_not_panic() {
489        let mut buf = create_buffer(40, 0);
490        let area = Rect::new(0, 0, 40, 0);
491
492        let entries = vec![OutputLine {
493            text: "hello".to_string(),
494            is_stderr: false,
495        }];
496
497        let pane = ScrollablePane::new(&entries, 0);
498        pane.render(area, &mut buf);
499    }
500
501    #[test]
502    fn zero_width_does_not_panic() {
503        let mut buf = create_buffer(0, 5);
504        let area = Rect::new(0, 0, 0, 5);
505
506        let entries = vec![OutputLine {
507            text: "hello".to_string(),
508            is_stderr: false,
509        }];
510
511        let pane = ScrollablePane::new(&entries, 0);
512        pane.render(area, &mut buf);
513    }
514
515    #[test]
516    fn builder_methods_chain() {
517        let entries: Vec<OutputLine> = vec![];
518        let pane = ScrollablePane::new(&entries, 0)
519            .with_title("Test")
520            .with_border_style(Style::default().fg(Color::Blue))
521            .with_empty_text("Nothing here");
522
523        assert_eq!(pane.title, Some("Test"));
524        assert_eq!(pane.empty_text, "Nothing here");
525    }
526
527    #[test]
528    fn output_line_stderr_vs_stdout_styles() {
529        let stdout = OutputLine {
530            text: "ok".to_string(),
531            is_stderr: false,
532        };
533        let stderr = OutputLine {
534            text: "err".to_string(),
535            is_stderr: true,
536        };
537
538        assert_eq!(stdout.text_style().fg, Some(color::TEXT));
539        assert_eq!(stderr.text_style().fg, Some(color::WARNING));
540    }
541
542    #[test]
543    fn log_level_styles_differ() {
544        let info = LogEntry {
545            level: LogLevel::Info,
546            message: String::new(),
547        };
548        let warn = LogEntry {
549            level: LogLevel::Warn,
550            message: String::new(),
551        };
552        let error = LogEntry {
553            level: LogLevel::Error,
554            message: String::new(),
555        };
556
557        assert_eq!(info.text_style().fg, Some(color::INACTIVE));
558        assert_eq!(warn.text_style().fg, Some(color::WARNING));
559        assert_eq!(error.text_style().fg, Some(color::ERROR));
560    }
561}