Skip to main content

saorsa_core/widget/
rich_log.rs

1//! Scrollable log widget that displays styled entries.
2//!
3//! Each entry is a line of [`Segment`]s. The log supports vertical
4//! scrolling via keyboard and optional auto-scrolling to the bottom
5//! when new entries are added.
6
7use crate::buffer::ScreenBuffer;
8use crate::cell::Cell;
9use crate::event::{Event, KeyCode, KeyEvent};
10use crate::geometry::Rect;
11use crate::segment::Segment;
12use crate::style::Style;
13use crate::text::truncate_to_display_width;
14use unicode_width::UnicodeWidthStr;
15
16use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
17
18/// A scrollable log widget that displays styled entries.
19///
20/// Each entry is a vector of [`Segment`]s representing one line.
21/// Supports vertical scrolling and optional auto-scroll to bottom.
22#[derive(Clone, Debug)]
23pub struct RichLog {
24    /// Log entries: each entry is a line of segments.
25    entries: Vec<Vec<Segment>>,
26    /// Index of the first visible entry.
27    scroll_offset: usize,
28    /// Base style for the log area.
29    style: Style,
30    /// Whether to auto-scroll to bottom when entries are added.
31    auto_scroll: bool,
32    /// Border style (optional).
33    border: BorderStyle,
34}
35
36impl RichLog {
37    /// Create a new empty log.
38    pub fn new() -> Self {
39        Self {
40            entries: Vec::new(),
41            scroll_offset: 0,
42            style: Style::default(),
43            auto_scroll: true,
44            border: BorderStyle::None,
45        }
46    }
47
48    /// Set the base style for the log area.
49    #[must_use]
50    pub fn with_style(mut self, style: Style) -> Self {
51        self.style = style;
52        self
53    }
54
55    /// Set the border style.
56    #[must_use]
57    pub fn with_border(mut self, border: BorderStyle) -> Self {
58        self.border = border;
59        self
60    }
61
62    /// Enable or disable auto-scrolling to the bottom on new entries.
63    #[must_use]
64    pub fn with_auto_scroll(mut self, enabled: bool) -> Self {
65        self.auto_scroll = enabled;
66        self
67    }
68
69    /// Add a log entry (single line of segments).
70    pub fn push(&mut self, entry: Vec<Segment>) {
71        self.entries.push(entry);
72        if self.auto_scroll {
73            // Will be applied on next render based on visible height;
74            // for now, set offset to show last entry.
75            // We use saturating_sub to handle the case where we don't know
76            // the visible height yet - scroll_to_bottom() can be called
77            // explicitly or it adjusts in render.
78            self.scroll_offset = self.entries.len().saturating_sub(1);
79        }
80    }
81
82    /// Add a plain text entry (convenience method).
83    pub fn push_text(&mut self, text: &str) {
84        self.entries.push(vec![Segment::new(text)]);
85        if self.auto_scroll {
86            self.scroll_offset = self.entries.len().saturating_sub(1);
87        }
88    }
89
90    /// Clear all entries and reset scroll.
91    pub fn clear(&mut self) {
92        self.entries.clear();
93        self.scroll_offset = 0;
94    }
95
96    /// Get total entry count.
97    pub fn len(&self) -> usize {
98        self.entries.len()
99    }
100
101    /// Check if the log has no entries.
102    pub fn is_empty(&self) -> bool {
103        self.entries.is_empty()
104    }
105
106    /// Scroll to the bottom (last entry visible).
107    pub fn scroll_to_bottom(&mut self) {
108        if !self.entries.is_empty() {
109            self.scroll_offset = self.entries.len().saturating_sub(1);
110        }
111    }
112
113    /// Scroll to the top (first entry visible).
114    pub fn scroll_to_top(&mut self) {
115        self.scroll_offset = 0;
116    }
117
118    /// Get the current scroll offset.
119    pub fn scroll_offset(&self) -> usize {
120        self.scroll_offset
121    }
122}
123
124impl Default for RichLog {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl Widget for RichLog {
131    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
132        if area.size.width == 0 || area.size.height == 0 {
133            return;
134        }
135
136        // Render border if any
137        super::border::render_border(area, self.border, self.style.clone(), buf);
138
139        let inner = super::border::inner_area(area, self.border);
140        if inner.size.width == 0 || inner.size.height == 0 {
141            return;
142        }
143
144        let height = inner.size.height as usize;
145        let width = inner.size.width as usize;
146
147        // Clamp scroll offset (use a local copy since render takes &self)
148        let max_offset = self.entries.len().saturating_sub(height.max(1));
149        let scroll = self.scroll_offset.min(max_offset);
150
151        let visible_end = (scroll + height).min(self.entries.len());
152
153        for (row, entry_idx) in (scroll..visible_end).enumerate() {
154            let y = inner.position.y + row as u16;
155            if let Some(entry) = self.entries.get(entry_idx) {
156                let mut col: u16 = 0;
157                for segment in entry {
158                    if col as usize >= width {
159                        break;
160                    }
161                    let remaining = width.saturating_sub(col as usize);
162                    let truncated = truncate_to_display_width(&segment.text, remaining);
163                    for ch in truncated.chars() {
164                        let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
165                        if col as usize + char_w > width {
166                            break;
167                        }
168                        let x = inner.position.x + col;
169                        buf.set(x, y, Cell::new(ch.to_string(), segment.style.clone()));
170                        col += char_w as u16;
171                    }
172                }
173            }
174        }
175    }
176}
177
178impl InteractiveWidget for RichLog {
179    fn handle_event(&mut self, event: &Event) -> EventResult {
180        let Event::Key(KeyEvent { code, .. }) = event else {
181            return EventResult::Ignored;
182        };
183
184        match code {
185            KeyCode::Up => {
186                if self.scroll_offset > 0 {
187                    self.scroll_offset -= 1;
188                    self.auto_scroll = false;
189                }
190                EventResult::Consumed
191            }
192            KeyCode::Down => {
193                if !self.entries.is_empty()
194                    && self.scroll_offset < self.entries.len().saturating_sub(1)
195                {
196                    self.scroll_offset += 1;
197                    self.auto_scroll = false;
198                }
199                EventResult::Consumed
200            }
201            KeyCode::PageUp => {
202                // Scroll by a page (assume ~20 lines if we don't know height)
203                let page = 20;
204                self.scroll_offset = self.scroll_offset.saturating_sub(page);
205                self.auto_scroll = false;
206                EventResult::Consumed
207            }
208            KeyCode::PageDown => {
209                let page = 20;
210                if !self.entries.is_empty() {
211                    self.scroll_offset =
212                        (self.scroll_offset + page).min(self.entries.len().saturating_sub(1));
213                    self.auto_scroll = false;
214                }
215                EventResult::Consumed
216            }
217            KeyCode::Home => {
218                self.scroll_to_top();
219                self.auto_scroll = false;
220                EventResult::Consumed
221            }
222            KeyCode::End => {
223                self.scroll_to_bottom();
224                // Scrolling to end re-enables auto_scroll behavior
225                EventResult::Consumed
226            }
227            _ => EventResult::Ignored,
228        }
229    }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235    use super::*;
236    use crate::geometry::Size;
237    use crate::style::Style;
238
239    fn make_segment(text: &str) -> Segment {
240        Segment::new(text)
241    }
242
243    fn styled_segment(text: &str, style: Style) -> Segment {
244        Segment::styled(text, style)
245    }
246
247    #[test]
248    fn new_log_is_empty() {
249        let log = RichLog::new();
250        assert!(log.is_empty());
251        assert_eq!(log.len(), 0);
252        assert_eq!(log.scroll_offset(), 0);
253    }
254
255    #[test]
256    fn default_matches_new() {
257        let log: RichLog = Default::default();
258        assert!(log.is_empty());
259        assert_eq!(log.len(), 0);
260    }
261
262    #[test]
263    fn push_adds_entries() {
264        let mut log = RichLog::new();
265        log.push(vec![make_segment("line 1")]);
266        log.push(vec![make_segment("line 2")]);
267        assert_eq!(log.len(), 2);
268        assert!(!log.is_empty());
269    }
270
271    #[test]
272    fn push_text_convenience() {
273        let mut log = RichLog::new();
274        log.push_text("hello");
275        assert_eq!(log.len(), 1);
276    }
277
278    #[test]
279    fn clear_resets() {
280        let mut log = RichLog::new();
281        log.push_text("a");
282        log.push_text("b");
283        log.clear();
284        assert!(log.is_empty());
285        assert_eq!(log.scroll_offset(), 0);
286    }
287
288    #[test]
289    fn render_empty_log() {
290        let log = RichLog::new();
291        let mut buf = ScreenBuffer::new(Size::new(20, 5));
292        log.render(Rect::new(0, 0, 20, 5), &mut buf);
293        // Should not panic; area remains blank
294        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some(" "));
295    }
296
297    #[test]
298    fn render_with_entries() {
299        let mut log = RichLog::new().with_auto_scroll(false);
300        log.push_text("hello");
301        log.push_text("world");
302
303        let mut buf = ScreenBuffer::new(Size::new(10, 5));
304        log.render(Rect::new(0, 0, 10, 5), &mut buf);
305
306        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("h"));
307        assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("e"));
308        assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("w"));
309    }
310
311    #[test]
312    fn render_with_multi_segment_entries() {
313        let mut log = RichLog::new().with_auto_scroll(false);
314        let bold = Style::new().bold(true);
315        log.push(vec![styled_segment("bold", bold), make_segment(" normal")]);
316
317        let mut buf = ScreenBuffer::new(Size::new(20, 5));
318        log.render(Rect::new(0, 0, 20, 5), &mut buf);
319
320        // 'b' should be bold
321        let cell_b = buf.get(0, 0);
322        assert!(cell_b.is_some());
323        assert_eq!(cell_b.map(|c| c.grapheme.as_str()), Some("b"));
324        assert!(cell_b.map(|c| c.style.bold).unwrap_or(false));
325
326        // ' ' after "bold" should be normal
327        let cell_space = buf.get(4, 0);
328        assert_eq!(cell_space.map(|c| c.grapheme.as_str()), Some(" "));
329    }
330
331    #[test]
332    fn render_with_border() {
333        let mut log = RichLog::new()
334            .with_border(BorderStyle::Single)
335            .with_auto_scroll(false);
336        log.push_text("hi");
337
338        let mut buf = ScreenBuffer::new(Size::new(10, 5));
339        log.render(Rect::new(0, 0, 10, 5), &mut buf);
340
341        // Top-left corner should be box drawing char
342        let corner = buf.get(0, 0).map(|c| c.grapheme.as_str());
343        assert_eq!(corner, Some("\u{250c}"));
344
345        // Content at (1, 1) inside border
346        assert_eq!(buf.get(1, 1).map(|c| c.grapheme.as_str()), Some("h"));
347    }
348
349    #[test]
350    fn scroll_operations() {
351        let mut log = RichLog::new().with_auto_scroll(false);
352        for i in 0..20 {
353            log.push_text(&format!("line {i}"));
354        }
355
356        log.scroll_to_bottom();
357        assert_eq!(log.scroll_offset(), 19);
358
359        log.scroll_to_top();
360        assert_eq!(log.scroll_offset(), 0);
361    }
362
363    #[test]
364    fn auto_scroll_on_push() {
365        let mut log = RichLog::new().with_auto_scroll(true);
366        log.push_text("a");
367        assert_eq!(log.scroll_offset(), 0);
368        log.push_text("b");
369        assert_eq!(log.scroll_offset(), 1);
370        log.push_text("c");
371        assert_eq!(log.scroll_offset(), 2);
372    }
373
374    #[test]
375    fn manual_scroll_disables_auto_scroll() {
376        let mut log = RichLog::new().with_auto_scroll(true);
377        for _ in 0..10 {
378            log.push_text("line");
379        }
380
381        // Scroll up manually
382        let event = Event::Key(KeyEvent::plain(KeyCode::Up));
383        let result = log.handle_event(&event);
384        assert_eq!(result, EventResult::Consumed);
385
386        // Auto-scroll should now be disabled
387        let prev_offset = log.scroll_offset();
388        log.push_text("new line");
389        // Offset should NOT auto-scroll because auto_scroll is disabled
390        assert_eq!(log.scroll_offset(), prev_offset);
391    }
392
393    #[test]
394    fn keyboard_navigation() {
395        let mut log = RichLog::new().with_auto_scroll(false);
396        for i in 0..30 {
397            log.push_text(&format!("line {i}"));
398        }
399
400        // Down key
401        let down = Event::Key(KeyEvent::plain(KeyCode::Down));
402        log.handle_event(&down);
403        assert_eq!(log.scroll_offset(), 1);
404
405        // Up key
406        let up = Event::Key(KeyEvent::plain(KeyCode::Up));
407        log.handle_event(&up);
408        assert_eq!(log.scroll_offset(), 0);
409
410        // Up at top stays at 0
411        log.handle_event(&up);
412        assert_eq!(log.scroll_offset(), 0);
413
414        // Page down
415        let pgdn = Event::Key(KeyEvent::plain(KeyCode::PageDown));
416        log.handle_event(&pgdn);
417        assert_eq!(log.scroll_offset(), 20);
418
419        // Page up
420        let pgup = Event::Key(KeyEvent::plain(KeyCode::PageUp));
421        log.handle_event(&pgup);
422        assert_eq!(log.scroll_offset(), 0);
423
424        // End key
425        let end = Event::Key(KeyEvent::plain(KeyCode::End));
426        log.handle_event(&end);
427        assert_eq!(log.scroll_offset(), 29);
428
429        // Home key
430        let home = Event::Key(KeyEvent::plain(KeyCode::Home));
431        log.handle_event(&home);
432        assert_eq!(log.scroll_offset(), 0);
433    }
434
435    #[test]
436    fn empty_log_keyboard_events_graceful() {
437        let mut log = RichLog::new();
438        let down = Event::Key(KeyEvent::plain(KeyCode::Down));
439        let result = log.handle_event(&down);
440        assert_eq!(result, EventResult::Consumed);
441        assert_eq!(log.scroll_offset(), 0);
442    }
443
444    #[test]
445    fn utf8_safety_wide_chars() {
446        let mut log = RichLog::new().with_auto_scroll(false);
447        log.push_text("日本語テスト");
448        log.push_text("Hello 🎉 World");
449
450        let mut buf = ScreenBuffer::new(Size::new(10, 5));
451        log.render(Rect::new(0, 0, 10, 5), &mut buf);
452
453        // Should not panic, and content should be truncated to width
454        let first_cell = buf.get(0, 0).map(|c| c.grapheme.as_str());
455        assert_eq!(first_cell, Some("日"));
456    }
457
458    #[test]
459    fn overflow_truncation() {
460        let mut log = RichLog::new().with_auto_scroll(false);
461        log.push_text("This is a very long line that should be truncated to fit");
462
463        let mut buf = ScreenBuffer::new(Size::new(10, 1));
464        log.render(Rect::new(0, 0, 10, 1), &mut buf);
465
466        // Only first 10 chars should appear: "This is a "
467        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("T"));
468        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some(" "));
469        assert_eq!(buf.get(5, 0).map(|c| c.grapheme.as_str()), Some("i"));
470    }
471
472    #[test]
473    fn unhandled_event_returns_ignored() {
474        let mut log = RichLog::new();
475        let event = Event::Key(KeyEvent::plain(KeyCode::Char('a')));
476        assert_eq!(log.handle_event(&event), EventResult::Ignored);
477    }
478
479    #[test]
480    fn builder_pattern() {
481        let log = RichLog::new()
482            .with_style(Style::new().bold(true))
483            .with_border(BorderStyle::Rounded)
484            .with_auto_scroll(false);
485
486        assert!(!log.auto_scroll);
487        assert!(matches!(log.border, BorderStyle::Rounded));
488    }
489}