Skip to main content

retroglyph_ui/widget/
log.rs

1//! [`Log`]: a scrolled-back tail of message lines.
2#[cfg(feature = "egc")]
3use alloc::vec::Vec;
4
5use retroglyph_core::grid::Rect;
6#[cfg(feature = "egc")]
7use retroglyph_core::layout::wrap;
8use retroglyph_core::text::Line;
9
10use super::{Measure, PrintLine, Widget};
11use crate::Surface;
12
13/// The tail of `messages` that fits in the area it's rendered into, oldest
14/// at top, newest at the bottom, each line clipped to `area.width()` via
15/// [`PrintLine`].
16///
17/// `offset` (set via [`Log::offset`], default `0`) scrolls back through
18/// history: `0` shows the most recent messages, and each increment moves
19/// the window one message further into the past. Like
20/// [`Table`](super::Table)'s `state.offset()`, this does not clamp `offset`:
21/// scrolling back past the start of `messages` shows fewer (or zero)
22/// lines rather than wrapping or panicking, and it's the caller's
23/// responsibility to stop incrementing `offset` past `messages.len()` if
24/// that's undesired. This is a different windowing direction than
25/// `Table`'s (anchored to the start and counting forward), so it isn't
26/// expressed as the same shared helper.
27///
28/// `messages` is a plain slice the caller owns and appends to (the same
29/// division of labor as [`ListState`](crate::ListState) for selection):
30/// this widget only reads it. Rows beyond the available messages are left
31/// untouched: compose with [`fill_rect`](crate::draw::fill_rect) first
32/// for a solid background if one is wanted.
33///
34/// By default each message is truncated to one row via [`PrintLine`], which clips anything
35/// past `area.width()`. [`Log::wrap`] (requires the `egc` feature) switches to word-wrapping
36/// each message across as many rows as it needs, via [`retroglyph_core::layout::wrap`], while
37/// keeping `offset` counting messages rather than rows: the window still fills from the newest
38/// message backward, but a message is only included if all of its wrapped rows fit in what's
39/// left of the surface, since there's no supported way to render only the bottom rows of an
40/// overflowing wrapped message. A message that doesn't fully fit is left out entirely, the same
41/// "rows beyond the available messages are left untouched" behavior as running out of messages.
42///
43/// # Examples
44///
45/// ```
46/// use retroglyph_core::grid::Rect;
47/// use retroglyph_core::text::Line;
48/// use retroglyph_core::grid::Grid;
49/// use retroglyph_ui::{Log, Surface, Widget};
50///
51/// let messages = [Line::raw("connected"), Line::raw("joined #general")];
52/// let area = Rect::new(0, 0, 20, 2);
53/// let mut grid = Grid::new(20, 2);
54/// Log::new(&messages).render(&mut Surface::new(&mut grid, area, 0));
55/// ```
56#[derive(Clone, Copy, Debug)]
57pub struct Log<'a> {
58    messages: &'a [Line],
59    offset: usize,
60    #[cfg(feature = "egc")]
61    wrap: bool,
62}
63
64impl<'a> Log<'a> {
65    /// A log tail over `messages`, starting at the most recent (`offset` 0).
66    #[must_use]
67    pub const fn new(messages: &'a [Line]) -> Self {
68        Self {
69            messages,
70            offset: 0,
71            #[cfg(feature = "egc")]
72            wrap: false,
73        }
74    }
75
76    /// Scroll back `offset` messages from the most recent.
77    #[must_use]
78    pub const fn offset(mut self, offset: usize) -> Self {
79        self.offset = offset;
80        self
81    }
82
83    /// Word-wraps each message across as many rows as it needs instead of clipping it to one
84    /// row, via [`retroglyph_core::layout::wrap`]. See the struct docs for how this interacts
85    /// with `offset`. Requires the `egc` feature.
86    #[cfg(feature = "egc")]
87    #[must_use]
88    pub const fn wrap(mut self, wrap: bool) -> Self {
89        self.wrap = wrap;
90        self
91    }
92}
93
94impl Measure for Log<'_> {
95    /// One row per message when not wrapped (`width` ignored); with [`Log::wrap`] set, the sum
96    /// of each message's wrapped row count at `width`. This is the height needed to show the
97    /// full backlog, not just the current `offset` window.
98    fn height_for(&self, width: u16) -> u16 {
99        #[cfg(feature = "egc")]
100        if self.wrap {
101            let total: usize = self.messages.iter().map(|m| wrap(m, width).len()).sum();
102            #[allow(clippy::cast_possible_truncation)]
103            let height = total.min(usize::from(u16::MAX)) as u16;
104            return height;
105        }
106        #[cfg(not(feature = "egc"))]
107        let _ = width;
108        #[allow(clippy::cast_possible_truncation)]
109        let height = self.messages.len().min(usize::from(u16::MAX)) as u16;
110        height
111    }
112}
113
114impl Widget for Log<'_> {
115    fn render(&self, surface: &mut Surface<'_>) {
116        let (width, height) = (surface.width(), surface.height());
117        let visible_height = usize::from(height);
118        if width == 0 || visible_height == 0 {
119            return;
120        }
121
122        // Index of the newest message in the visible window; `None` once
123        // `offset` has scrolled back past the start of `messages`.
124        let Some(bottom) = self
125            .messages
126            .len()
127            .checked_sub(self.offset.saturating_add(1))
128        else {
129            return;
130        };
131
132        #[cfg(feature = "egc")]
133        if self.wrap {
134            self.render_wrapped(surface, bottom, visible_height, width);
135            return;
136        }
137
138        let top = bottom.saturating_sub(visible_height - 1);
139
140        // `scope`, unlike `put`, addresses the same grid-space `surface.area()` does, so each
141        // row's rect is built from `area`'s own top-left.
142        let area = surface.area();
143        for (row, message) in self.messages[top..=bottom].iter().enumerate() {
144            // `row` indexes a slice of at most `visible_height` messages, itself bounded by this
145            // surface's own `u16` height, so narrowing it back is always exact.
146            #[allow(clippy::cast_possible_truncation)]
147            let y = area.top() + row as u16;
148            let row_area = Rect::new(area.left(), y, width, 1);
149            PrintLine::new(message).render(&mut surface.scope(row_area));
150        }
151    }
152}
153
154#[cfg(feature = "egc")]
155impl Log<'_> {
156    /// The [`Log::wrap`] render path: walks backward from `bottom` (the newest visible message)
157    /// wrapping each message at `width` and accumulating its row count, until the row budget
158    /// (`visible_height`) would be exceeded or `messages` is exhausted. A message whose wrapped
159    /// rows would overflow the remaining budget is left out entirely rather than rendering only
160    /// part of it (see the struct docs for why), so fewer than `visible_height` rows can end up
161    /// drawn even with more history available.
162    fn render_wrapped(
163        &self,
164        surface: &mut Surface<'_>,
165        bottom: usize,
166        visible_height: usize,
167        width: u16,
168    ) {
169        let mut included: Vec<Vec<Line>> = Vec::new();
170        let mut used = 0usize;
171        for message in self.messages[..=bottom].iter().rev() {
172            let rows = wrap(message, width);
173            if used + rows.len() > visible_height {
174                break;
175            }
176            used += rows.len();
177            included.push(rows);
178        }
179
180        let area = surface.area();
181        let mut y = area.top();
182        for rows in included.into_iter().rev() {
183            for row in rows {
184                let row_area = Rect::new(area.left(), y, width, 1);
185                PrintLine::new(&row).render(&mut surface.scope(row_area));
186                y += 1;
187            }
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use alloc::vec::Vec;
195
196    use retroglyph_core::grid::{Grid, Pos};
197
198    use super::*;
199
200    fn lines(texts: &[&str]) -> Vec<Line> {
201        texts.iter().map(|t| Line::raw(*t)).collect()
202    }
203
204    #[test]
205    fn shows_the_most_recent_messages_oldest_at_top() {
206        // 2 visible rows; 4 messages, so only the last two should show.
207        let area = Rect::new(0, 0, 20, 2);
208        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
209
210        let mut grid = Grid::new(20, 2);
211        Log::new(&messages).render(&mut Surface::new(&mut grid, area, 0));
212
213        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'c'); // "charlie"
214        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'd'); // "delta"
215    }
216
217    #[test]
218    fn height_for_is_the_message_count() {
219        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
220        assert_eq!(Log::new(&messages).height_for(80), 4);
221    }
222
223    #[test]
224    fn offset_scrolls_back_through_history() {
225        let area = Rect::new(0, 0, 20, 2);
226        let messages = lines(&["alpha", "bravo", "charlie", "delta"]);
227
228        let mut grid = Grid::new(20, 2);
229        Log::new(&messages)
230            .offset(1)
231            .render(&mut Surface::new(&mut grid, area, 0)); // one message back from the tail
232
233        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'b'); // "bravo"
234        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'c'); // "charlie"
235    }
236
237    #[test]
238    fn offset_past_the_start_shows_fewer_lines_without_panicking() {
239        let area = Rect::new(0, 0, 20, 2);
240        let messages = lines(&["alpha", "bravo"]);
241
242        let mut grid = Grid::new(20, 2);
243        Log::new(&messages)
244            .offset(5)
245            .render(&mut Surface::new(&mut grid, area, 0)); // scrolled back past the start
246
247        // Nothing drawn; both rows stay whatever they were (default/empty).
248        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
249        assert_eq!(grid[Pos::new(0, 1)].glyph(), ' ');
250    }
251
252    #[test]
253    fn fewer_messages_than_visible_rows_leaves_the_rest_untouched() {
254        let area = Rect::new(0, 0, 20, 4);
255        let messages = lines(&["only"]);
256
257        let mut grid = Grid::new(20, 4);
258        Log::new(&messages).render(&mut Surface::new(&mut grid, area, 0));
259
260        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'o'); // "only"
261        assert_eq!(grid[Pos::new(0, 1)].glyph(), ' '); // untouched
262        assert_eq!(grid[Pos::new(0, 2)].glyph(), ' '); // untouched
263    }
264
265    #[test]
266    fn clips_long_lines_to_area_width() {
267        let area = Rect::new(0, 0, 5, 1);
268        let messages = lines(&["a much longer message than fits"]);
269
270        let mut grid = Grid::new(5, 1);
271        Log::new(&messages).render(&mut Surface::new(&mut grid, area, 0));
272
273        // "a much longer..." clipped to 5 columns is "a muc".
274        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'c');
275    }
276
277    #[cfg(feature = "egc")]
278    #[test]
279    fn wrap_word_wraps_a_long_message_across_rows() {
280        let area = Rect::new(0, 0, 7, 3);
281        let messages = lines(&["hello world"]);
282
283        let mut grid = Grid::new(7, 3);
284        Log::new(&messages)
285            .wrap(true)
286            .render(&mut Surface::new(&mut grid, area, 0));
287
288        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h'); // "hello"
289        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'w'); // "world"
290        assert_eq!(grid[Pos::new(0, 2)].glyph(), ' '); // untouched
291    }
292
293    #[cfg(feature = "egc")]
294    #[test]
295    fn height_for_sums_wrapped_rows_when_wrap_is_set() {
296        let messages = lines(&["hello world", "hi"]);
297        assert_eq!(Log::new(&messages).wrap(true).height_for(7), 3); // 2 rows + 1 row
298        assert_eq!(Log::new(&messages).height_for(7), 2); // unwrapped: one row per message
299    }
300
301    #[cfg(feature = "egc")]
302    #[test]
303    fn wrap_keeps_offset_counting_whole_messages_not_rows() {
304        // "hello world" wraps to 2 rows; offset(1) should skip the whole newer message
305        // ("hi", 1 row) rather than 1 row of it.
306        let area = Rect::new(0, 0, 7, 4);
307        let messages = lines(&["hello world", "hi"]);
308
309        let mut grid = Grid::new(7, 4);
310        Log::new(&messages)
311            .wrap(true)
312            .offset(1)
313            .render(&mut Surface::new(&mut grid, area, 0));
314
315        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h'); // "hello"
316        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'w'); // "world"
317        assert_eq!(grid[Pos::new(0, 2)].glyph(), ' '); // "hi" scrolled out entirely
318    }
319
320    #[cfg(feature = "egc")]
321    #[test]
322    fn wrap_leaves_out_a_message_that_would_only_partially_fit() {
323        // Only 1 row of budget left after "hi" (the newest message); "hello world" needs 2 rows,
324        // so it's left out entirely rather than showing only its bottom row.
325        let area = Rect::new(0, 0, 7, 2);
326        let messages = lines(&["hello world", "hi"]);
327
328        let mut grid = Grid::new(7, 2);
329        Log::new(&messages)
330            .wrap(true)
331            .render(&mut Surface::new(&mut grid, area, 0));
332
333        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h'); // "hi"
334        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
335        assert_eq!(grid[Pos::new(0, 1)].glyph(), ' '); // untouched
336    }
337}