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///
29/// # Examples
30///
31/// ```
32/// use retroglyph_core::text::Line;
33/// use retroglyph_core::{Headless, Rect, Terminal};
34/// use retroglyph_widgets::{Log, Widget};
35///
36/// let messages = [Line::raw("connected"), Line::raw("joined #general")];
37/// let mut term = Terminal::new(Headless::new(20, 2));
38/// Log::new(&messages).render(Rect::new(0, 0, 20, 2), &mut term);
39/// ```
40#[derive(Clone, Copy, Debug)]
41pub struct Log<'a> {
42    messages: &'a [Line],
43    offset: usize,
44}
45
46impl<'a> Log<'a> {
47    /// A log tail over `messages`, starting at the most recent (`offset` 0).
48    #[must_use]
49    pub const fn new(messages: &'a [Line]) -> Self {
50        Self {
51            messages,
52            offset: 0,
53        }
54    }
55
56    /// Scroll back `offset` messages from the most recent.
57    #[must_use]
58    pub const fn offset(mut self, offset: usize) -> Self {
59        self.offset = offset;
60        self
61    }
62}
63
64impl<B: Backend> Widget<B> for Log<'_> {
65    fn render(self, area: Rect, term: &mut Terminal<B>) {
66        let visible_height = area.height_usize();
67        if area.width() == 0 || visible_height == 0 {
68            return;
69        }
70
71        // Index of the newest message in the visible window; `None` once
72        // `offset` has scrolled back past the start of `messages`.
73        let Some(bottom) = self
74            .messages
75            .len()
76            .checked_sub(self.offset.saturating_add(1))
77        else {
78            return;
79        };
80        let top = bottom.saturating_sub(visible_height - 1);
81
82        for (row, message) in self.messages[top..=bottom].iter().enumerate() {
83            let y = area.top() + row as u16;
84            let row_area = Rect::new(area.left(), y, area.width(), 1);
85            PrintLine::new(message).render(row_area, term);
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use retroglyph_core::Headless;
93
94    use super::*;
95
96    fn lines(texts: &[&str]) -> Vec<Line> {
97        texts.iter().map(|t| Line::raw(*t)).collect()
98    }
99
100    #[test]
101    fn shows_the_most_recent_messages_oldest_at_top() {
102        // 2 visible rows; 4 messages, so only the last two should show.
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).render(area, &mut term);
108
109        assert_eq!(term.grid().get(0, 0).glyph(), 'c'); // "charlie"
110        assert_eq!(term.grid().get(0, 1).glyph(), 'd'); // "delta"
111    }
112
113    #[test]
114    fn offset_scrolls_back_through_history() {
115        let area = Rect::new(0, 0, 20, 2);
116        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
117
118        let mut term = Terminal::new(Headless::new(20, 2));
119        Log::new(&messages).offset(1).render(area, &mut term); // one message back from the tail
120
121        assert_eq!(term.grid().get(0, 0).glyph(), 'b'); // "bravo"
122        assert_eq!(term.grid().get(0, 1).glyph(), 'c'); // "charlie"
123    }
124
125    #[test]
126    fn offset_past_the_start_shows_fewer_lines_without_panicking() {
127        let area = Rect::new(0, 0, 20, 2);
128        let messages = lines(&["alpha", "bravo"]);
129
130        let mut term = Terminal::new(Headless::new(20, 2));
131        Log::new(&messages).offset(5).render(area, &mut term); // scrolled back past the start
132
133        // Nothing drawn; both rows stay whatever they were (default/empty).
134        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
135        assert_eq!(term.grid().get(0, 1).glyph(), ' ');
136    }
137
138    #[test]
139    fn fewer_messages_than_visible_rows_leaves_the_rest_untouched() {
140        let area = Rect::new(0, 0, 20, 4);
141        let messages = lines(&["only"]);
142
143        let mut term = Terminal::new(Headless::new(20, 4));
144        Log::new(&messages).render(area, &mut term);
145
146        assert_eq!(term.grid().get(0, 0).glyph(), 'o'); // "only"
147        assert_eq!(term.grid().get(0, 1).glyph(), ' '); // untouched
148        assert_eq!(term.grid().get(0, 2).glyph(), ' '); // untouched
149    }
150
151    #[test]
152    fn clips_long_lines_to_area_width() {
153        let area = Rect::new(0, 0, 5, 1);
154        let messages = lines(&["a much longer message than fits"]);
155
156        let mut term = Terminal::new(Headless::new(5, 1));
157        Log::new(&messages).render(area, &mut term);
158
159        // "a much longer..." clipped to 5 columns is "a muc".
160        assert_eq!(term.grid().get(4, 0).glyph(), 'c');
161    }
162}