Skip to main content

retroglyph_widgets/widget/
log.rs

1//! [`Log`]: a scrolled-back tail of message lines.
2use retroglyph_core::Rect;
3use retroglyph_core::text::Line;
4
5use super::{PrintLine, Widget};
6use crate::Surface;
7
8/// The tail of `messages` that fits in the area it's rendered into, oldest
9/// at top, newest at the bottom, each line clipped to `area.width()` via
10/// [`PrintLine`].
11///
12/// `offset` (set via [`Log::offset`], default `0`) scrolls back through
13/// history: `0` shows the most recent messages, and each increment moves
14/// the window one message further into the past. Like
15/// [`Table`](super::Table)'s `state.offset()`, this does not clamp `offset`:
16/// scrolling back past the start of `messages` shows fewer (or zero)
17/// lines rather than wrapping or panicking, and it's the caller's
18/// responsibility to stop incrementing `offset` past `messages.len()` if
19/// that's undesired. This is a different windowing direction than
20/// `Table`'s (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::Rect;
33/// use retroglyph_core::text::Line;
34/// use retroglyph_core::Grid;
35/// use retroglyph_widgets::{Log, Surface, Widget};
36///
37/// let messages = [Line::raw("connected"), Line::raw("joined #general")];
38/// let area = Rect::new(0, 0, 20, 2);
39/// let mut grid = Grid::new(20, 2);
40/// Log::new(&messages).render(area, &mut Surface::new(&mut grid, area, 0));
41/// ```
42#[derive(Clone, Copy, Debug)]
43pub struct Log<'a> {
44    messages: &'a [Line],
45    offset: usize,
46}
47
48impl<'a> Log<'a> {
49    /// A log tail over `messages`, starting at the most recent (`offset` 0).
50    #[must_use]
51    pub const fn new(messages: &'a [Line]) -> Self {
52        Self {
53            messages,
54            offset: 0,
55        }
56    }
57
58    /// Scroll back `offset` messages from the most recent.
59    #[must_use]
60    pub const fn offset(mut self, offset: usize) -> Self {
61        self.offset = offset;
62        self
63    }
64}
65
66impl Widget for Log<'_> {
67    fn render(&self, area: Rect, surface: &mut Surface<'_>) {
68        let visible_height = area.height_usize();
69        if area.width() == 0 || visible_height == 0 {
70            return;
71        }
72
73        // Index of the newest message in the visible window; `None` once
74        // `offset` has scrolled back past the start of `messages`.
75        let Some(bottom) = self
76            .messages
77            .len()
78            .checked_sub(self.offset.saturating_add(1))
79        else {
80            return;
81        };
82        let top = bottom.saturating_sub(visible_height - 1);
83
84        for (row, message) in self.messages[top..=bottom].iter().enumerate() {
85            // `row` indexes a slice of at most `visible_height` messages, itself bounded by
86            // `area`'s `u16` height, so narrowing it back is always exact.
87            #[allow(clippy::cast_possible_truncation)]
88            let y = area.top() + row as u16;
89            let row_area = Rect::new(area.left(), y, area.width(), 1);
90            PrintLine::new(message).render(row_area, surface);
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use retroglyph_core::{Grid, Pos};
98
99    use super::*;
100
101    fn lines(texts: &[&str]) -> Vec<Line> {
102        texts.iter().map(|t| Line::raw(*t)).collect()
103    }
104
105    #[test]
106    fn shows_the_most_recent_messages_oldest_at_top() {
107        // 2 visible rows; 4 messages, so only the last two should show.
108        let area = Rect::new(0, 0, 20, 2);
109        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
110
111        let mut grid = Grid::new(20, 2);
112        Log::new(&messages).render(area, &mut Surface::new(&mut grid, area, 0));
113
114        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'c'); // "charlie"
115        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'd'); // "delta"
116    }
117
118    #[test]
119    fn offset_scrolls_back_through_history() {
120        let area = Rect::new(0, 0, 20, 2);
121        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
122
123        let mut grid = Grid::new(20, 2);
124        Log::new(&messages)
125            .offset(1)
126            .render(area, &mut Surface::new(&mut grid, area, 0)); // one message back from the tail
127
128        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'b'); // "bravo"
129        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'c'); // "charlie"
130    }
131
132    #[test]
133    fn offset_past_the_start_shows_fewer_lines_without_panicking() {
134        let area = Rect::new(0, 0, 20, 2);
135        let messages = lines(&["alpha", "bravo"]);
136
137        let mut grid = Grid::new(20, 2);
138        Log::new(&messages)
139            .offset(5)
140            .render(area, &mut Surface::new(&mut grid, area, 0)); // scrolled back past the start
141
142        // Nothing drawn; both rows stay whatever they were (default/empty).
143        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
144        assert_eq!(grid[Pos::new(0, 1)].glyph(), ' ');
145    }
146
147    #[test]
148    fn fewer_messages_than_visible_rows_leaves_the_rest_untouched() {
149        let area = Rect::new(0, 0, 20, 4);
150        let messages = lines(&["only"]);
151
152        let mut grid = Grid::new(20, 4);
153        Log::new(&messages).render(area, &mut Surface::new(&mut grid, area, 0));
154
155        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'o'); // "only"
156        assert_eq!(grid[Pos::new(0, 1)].glyph(), ' '); // untouched
157        assert_eq!(grid[Pos::new(0, 2)].glyph(), ' '); // untouched
158    }
159
160    #[test]
161    fn clips_long_lines_to_area_width() {
162        let area = Rect::new(0, 0, 5, 1);
163        let messages = lines(&["a much longer message than fits"]);
164
165        let mut grid = Grid::new(5, 1);
166        Log::new(&messages).render(area, &mut Surface::new(&mut grid, area, 0));
167
168        // "a much longer..." clipped to 5 columns is "a muc".
169        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'c');
170    }
171}