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