Skip to main content

retroglyph_widgets/widget/
log.rs

1//! [`Log`]: a scrolled-back tail of message lines.
2use retroglyph_core::text::Line;
3use retroglyph_core::{Backend, Rect, Terminal};
4
5use super::{PrintLine, Widget};
6
7/// The tail of `messages` that fits in the area it's rendered into, oldest
8/// at top, newest at the bottom, each line clipped to `area.width()` via
9/// [`PrintLine`].
10///
11/// `offset` (set via [`Log::offset`], default `0`) scrolls back through
12/// history: `0` shows the most recent messages, and each increment moves
13/// the window one message further into the past. Like
14/// [`Table`](super::Table)'s `state.offset()`, this does not clamp `offset`
15/// -- scrolling back past the start of `messages` shows fewer (or zero)
16/// lines rather than wrapping or panicking, and it's the caller's
17/// responsibility to stop incrementing `offset` past `messages.len()` if
18/// that's undesired. This is a different windowing direction than
19/// `Table`'s (anchored to the most recent entry and counting backward,
20/// rather than anchored to the start and counting forward), so it isn't
21/// expressed as the same shared helper.
22///
23/// `messages` is a plain slice the caller owns and appends to (the same
24/// division of labor as [`ListState`](crate::ListState) for selection):
25/// this widget only reads it. Rows beyond the available messages are left
26/// untouched -- compose with [`fill_rect`](crate::draw::fill_rect) first
27/// for a solid background if one is wanted.
28#[derive(Clone, Copy, Debug)]
29pub struct Log<'a> {
30    messages: &'a [Line],
31    offset: usize,
32}
33
34impl<'a> Log<'a> {
35    /// A log tail over `messages`, starting at the most recent (`offset` 0).
36    #[must_use]
37    pub const fn new(messages: &'a [Line]) -> Self {
38        Self {
39            messages,
40            offset: 0,
41        }
42    }
43
44    /// Scroll back `offset` messages from the most recent.
45    #[must_use]
46    pub const fn offset(mut self, offset: usize) -> Self {
47        self.offset = offset;
48        self
49    }
50}
51
52impl<B: Backend> Widget<B> for Log<'_> {
53    fn render(self, area: Rect, term: &mut Terminal<B>) {
54        let visible_height = area.height_usize();
55        if area.width() == 0 || visible_height == 0 {
56            return;
57        }
58
59        // Index of the newest message in the visible window; `None` once
60        // `offset` has scrolled back past the start of `messages`.
61        let Some(bottom) = self
62            .messages
63            .len()
64            .checked_sub(self.offset.saturating_add(1))
65        else {
66            return;
67        };
68        let top = bottom.saturating_sub(visible_height - 1);
69
70        for (row, message) in self.messages[top..=bottom].iter().enumerate() {
71            let y = area.top() + row as u16;
72            let row_area = Rect::new(area.left(), y, area.width(), 1);
73            PrintLine::new(message).render(row_area, term);
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use retroglyph_core::Headless;
81
82    use super::*;
83
84    fn lines(texts: &[&str]) -> Vec<Line> {
85        texts.iter().map(|t| Line::raw(*t)).collect()
86    }
87
88    #[test]
89    fn shows_the_most_recent_messages_oldest_at_top() {
90        // 2 visible rows; 4 messages, so only the last two should show.
91        let area = Rect::new(0, 0, 20, 2);
92        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
93
94        let mut term = Terminal::new(Headless::new(20, 2));
95        Log::new(&messages).render(area, &mut term);
96
97        assert_eq!(term.grid().get(0, 0).glyph(), 'c'); // "charlie"
98        assert_eq!(term.grid().get(0, 1).glyph(), 'd'); // "delta"
99    }
100
101    #[test]
102    fn offset_scrolls_back_through_history() {
103        let area = Rect::new(0, 0, 20, 2);
104        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
105
106        let mut term = Terminal::new(Headless::new(20, 2));
107        Log::new(&messages).offset(1).render(area, &mut term); // one message back from the tail
108
109        assert_eq!(term.grid().get(0, 0).glyph(), 'b'); // "bravo"
110        assert_eq!(term.grid().get(0, 1).glyph(), 'c'); // "charlie"
111    }
112
113    #[test]
114    fn offset_past_the_start_shows_fewer_lines_without_panicking() {
115        let area = Rect::new(0, 0, 20, 2);
116        let messages = lines(&["alpha", "bravo"]);
117
118        let mut term = Terminal::new(Headless::new(20, 2));
119        Log::new(&messages).offset(5).render(area, &mut term); // scrolled back past the start
120
121        // Nothing drawn; both rows stay whatever they were (default/empty).
122        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
123        assert_eq!(term.grid().get(0, 1).glyph(), ' ');
124    }
125
126    #[test]
127    fn fewer_messages_than_visible_rows_leaves_the_rest_untouched() {
128        let area = Rect::new(0, 0, 20, 4);
129        let messages = lines(&["only"]);
130
131        let mut term = Terminal::new(Headless::new(20, 4));
132        Log::new(&messages).render(area, &mut term);
133
134        assert_eq!(term.grid().get(0, 0).glyph(), 'o'); // "only"
135        assert_eq!(term.grid().get(0, 1).glyph(), ' '); // untouched
136        assert_eq!(term.grid().get(0, 2).glyph(), ' '); // untouched
137    }
138
139    #[test]
140    fn clips_long_lines_to_area_width() {
141        let area = Rect::new(0, 0, 5, 1);
142        let messages = lines(&["a much longer message than fits"]);
143
144        let mut term = Terminal::new(Headless::new(5, 1));
145        Log::new(&messages).render(area, &mut term);
146
147        // "a much longer..." clipped to 5 columns is "a muc".
148        assert_eq!(term.grid().get(4, 0).glyph(), 'c');
149    }
150}