use crate::buffer::ScreenBuffer;
use crate::cell::Cell;
use crate::event::{Event, KeyCode, KeyEvent};
use crate::geometry::Rect;
use crate::segment::Segment;
use crate::style::Style;
use crate::text::truncate_to_display_width;
use unicode_width::UnicodeWidthStr;
use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
#[derive(Clone, Debug)]
pub struct RichLog {
entries: Vec<Vec<Segment>>,
scroll_offset: usize,
style: Style,
auto_scroll: bool,
border: BorderStyle,
}
impl RichLog {
pub fn new() -> Self {
Self {
entries: Vec::new(),
scroll_offset: 0,
style: Style::default(),
auto_scroll: true,
border: BorderStyle::None,
}
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_border(mut self, border: BorderStyle) -> Self {
self.border = border;
self
}
pub fn base_style(&self) -> &Style {
&self.style
}
pub fn set_base_style(&mut self, style: Style) {
self.style = style;
}
pub fn border_style_kind(&self) -> BorderStyle {
self.border
}
pub fn set_border(&mut self, border: BorderStyle) {
self.border = border;
}
#[must_use]
pub fn with_auto_scroll(mut self, enabled: bool) -> Self {
self.auto_scroll = enabled;
self
}
pub fn push(&mut self, entry: Vec<Segment>) {
self.entries.push(entry);
if self.auto_scroll {
self.scroll_offset = self.entries.len().saturating_sub(1);
}
}
pub fn push_text(&mut self, text: &str) {
self.entries.push(vec![Segment::new(text)]);
if self.auto_scroll {
self.scroll_offset = self.entries.len().saturating_sub(1);
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.scroll_offset = 0;
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn scroll_to_bottom(&mut self) {
if !self.entries.is_empty() {
self.scroll_offset = self.entries.len().saturating_sub(1);
}
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn scroll_up_by(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
self.auto_scroll = false;
}
pub fn scroll_down_by(&mut self, lines: usize) {
if self.entries.is_empty() {
return;
}
let max = self.entries.len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + lines).min(max);
self.auto_scroll = false;
}
}
impl Default for RichLog {
fn default() -> Self {
Self::new()
}
}
impl Widget for RichLog {
fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
if area.size.width == 0 || area.size.height == 0 {
return;
}
super::border::render_border(area, self.border, self.style.clone(), buf);
let inner = super::border::inner_area(area, self.border);
if inner.size.width == 0 || inner.size.height == 0 {
return;
}
let height = inner.size.height as usize;
let width = inner.size.width as usize;
let max_offset = self.entries.len().saturating_sub(height.max(1));
let scroll = self.scroll_offset.min(max_offset);
let visible_end = (scroll + height).min(self.entries.len());
for (row, entry_idx) in (scroll..visible_end).enumerate() {
let y = inner.position.y + row as u16;
if let Some(entry) = self.entries.get(entry_idx) {
let mut col: u16 = 0;
for segment in entry {
if col as usize >= width {
break;
}
let remaining = width.saturating_sub(col as usize);
let truncated = truncate_to_display_width(&segment.text, remaining);
for ch in truncated.chars() {
let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
if col as usize + char_w > width {
break;
}
let x = inner.position.x + col;
buf.set(x, y, Cell::new(ch.to_string(), segment.style.clone()));
col += char_w as u16;
}
}
}
}
}
}
impl InteractiveWidget for RichLog {
fn handle_event(&mut self, event: &Event) -> EventResult {
let Event::Key(KeyEvent { code, .. }) = event else {
return EventResult::Ignored;
};
match code {
KeyCode::Up => {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
self.auto_scroll = false;
}
EventResult::Consumed
}
KeyCode::Down => {
if !self.entries.is_empty()
&& self.scroll_offset < self.entries.len().saturating_sub(1)
{
self.scroll_offset += 1;
self.auto_scroll = false;
}
EventResult::Consumed
}
KeyCode::PageUp => {
let page = 20;
self.scroll_offset = self.scroll_offset.saturating_sub(page);
self.auto_scroll = false;
EventResult::Consumed
}
KeyCode::PageDown => {
let page = 20;
if !self.entries.is_empty() {
self.scroll_offset =
(self.scroll_offset + page).min(self.entries.len().saturating_sub(1));
self.auto_scroll = false;
}
EventResult::Consumed
}
KeyCode::Home => {
self.scroll_to_top();
self.auto_scroll = false;
EventResult::Consumed
}
KeyCode::End => {
self.scroll_to_bottom();
EventResult::Consumed
}
_ => EventResult::Ignored,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::geometry::Size;
use crate::style::Style;
fn make_segment(text: &str) -> Segment {
Segment::new(text)
}
fn styled_segment(text: &str, style: Style) -> Segment {
Segment::styled(text, style)
}
#[test]
fn new_log_is_empty() {
let log = RichLog::new();
assert!(log.is_empty());
assert_eq!(log.len(), 0);
assert_eq!(log.scroll_offset(), 0);
}
#[test]
fn default_matches_new() {
let log: RichLog = Default::default();
assert!(log.is_empty());
assert_eq!(log.len(), 0);
}
#[test]
fn push_adds_entries() {
let mut log = RichLog::new();
log.push(vec![make_segment("line 1")]);
log.push(vec![make_segment("line 2")]);
assert_eq!(log.len(), 2);
assert!(!log.is_empty());
}
#[test]
fn push_text_convenience() {
let mut log = RichLog::new();
log.push_text("hello");
assert_eq!(log.len(), 1);
}
#[test]
fn clear_resets() {
let mut log = RichLog::new();
log.push_text("a");
log.push_text("b");
log.clear();
assert!(log.is_empty());
assert_eq!(log.scroll_offset(), 0);
}
#[test]
fn render_empty_log() {
let log = RichLog::new();
let mut buf = ScreenBuffer::new(Size::new(20, 5));
log.render(Rect::new(0, 0, 20, 5), &mut buf);
assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some(" "));
}
#[test]
fn render_with_entries() {
let mut log = RichLog::new().with_auto_scroll(false);
log.push_text("hello");
log.push_text("world");
let mut buf = ScreenBuffer::new(Size::new(10, 5));
log.render(Rect::new(0, 0, 10, 5), &mut buf);
assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("h"));
assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("e"));
assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("w"));
}
#[test]
fn render_with_multi_segment_entries() {
let mut log = RichLog::new().with_auto_scroll(false);
let bold = Style::new().bold(true);
log.push(vec![styled_segment("bold", bold), make_segment(" normal")]);
let mut buf = ScreenBuffer::new(Size::new(20, 5));
log.render(Rect::new(0, 0, 20, 5), &mut buf);
let cell_b = buf.get(0, 0);
assert!(cell_b.is_some());
assert_eq!(cell_b.map(|c| c.grapheme.as_str()), Some("b"));
assert!(cell_b.map(|c| c.style.bold).unwrap_or(false));
let cell_space = buf.get(4, 0);
assert_eq!(cell_space.map(|c| c.grapheme.as_str()), Some(" "));
}
#[test]
fn render_with_border() {
let mut log = RichLog::new()
.with_border(BorderStyle::Single)
.with_auto_scroll(false);
log.push_text("hi");
let mut buf = ScreenBuffer::new(Size::new(10, 5));
log.render(Rect::new(0, 0, 10, 5), &mut buf);
let corner = buf.get(0, 0).map(|c| c.grapheme.as_str());
assert_eq!(corner, Some("\u{250c}"));
assert_eq!(buf.get(1, 1).map(|c| c.grapheme.as_str()), Some("h"));
}
#[test]
fn scroll_operations() {
let mut log = RichLog::new().with_auto_scroll(false);
for i in 0..20 {
log.push_text(&format!("line {i}"));
}
log.scroll_to_bottom();
assert_eq!(log.scroll_offset(), 19);
log.scroll_to_top();
assert_eq!(log.scroll_offset(), 0);
}
#[test]
fn auto_scroll_on_push() {
let mut log = RichLog::new().with_auto_scroll(true);
log.push_text("a");
assert_eq!(log.scroll_offset(), 0);
log.push_text("b");
assert_eq!(log.scroll_offset(), 1);
log.push_text("c");
assert_eq!(log.scroll_offset(), 2);
}
#[test]
fn manual_scroll_disables_auto_scroll() {
let mut log = RichLog::new().with_auto_scroll(true);
for _ in 0..10 {
log.push_text("line");
}
let event = Event::Key(KeyEvent::plain(KeyCode::Up));
let result = log.handle_event(&event);
assert_eq!(result, EventResult::Consumed);
let prev_offset = log.scroll_offset();
log.push_text("new line");
assert_eq!(log.scroll_offset(), prev_offset);
}
#[test]
fn keyboard_navigation() {
let mut log = RichLog::new().with_auto_scroll(false);
for i in 0..30 {
log.push_text(&format!("line {i}"));
}
let down = Event::Key(KeyEvent::plain(KeyCode::Down));
log.handle_event(&down);
assert_eq!(log.scroll_offset(), 1);
let up = Event::Key(KeyEvent::plain(KeyCode::Up));
log.handle_event(&up);
assert_eq!(log.scroll_offset(), 0);
log.handle_event(&up);
assert_eq!(log.scroll_offset(), 0);
let pgdn = Event::Key(KeyEvent::plain(KeyCode::PageDown));
log.handle_event(&pgdn);
assert_eq!(log.scroll_offset(), 20);
let pgup = Event::Key(KeyEvent::plain(KeyCode::PageUp));
log.handle_event(&pgup);
assert_eq!(log.scroll_offset(), 0);
let end = Event::Key(KeyEvent::plain(KeyCode::End));
log.handle_event(&end);
assert_eq!(log.scroll_offset(), 29);
let home = Event::Key(KeyEvent::plain(KeyCode::Home));
log.handle_event(&home);
assert_eq!(log.scroll_offset(), 0);
}
#[test]
fn empty_log_keyboard_events_graceful() {
let mut log = RichLog::new();
let down = Event::Key(KeyEvent::plain(KeyCode::Down));
let result = log.handle_event(&down);
assert_eq!(result, EventResult::Consumed);
assert_eq!(log.scroll_offset(), 0);
}
#[test]
fn utf8_safety_wide_chars() {
let mut log = RichLog::new().with_auto_scroll(false);
log.push_text("日本語テスト");
log.push_text("Hello 🎉 World");
let mut buf = ScreenBuffer::new(Size::new(10, 5));
log.render(Rect::new(0, 0, 10, 5), &mut buf);
let first_cell = buf.get(0, 0).map(|c| c.grapheme.as_str());
assert_eq!(first_cell, Some("日"));
}
#[test]
fn overflow_truncation() {
let mut log = RichLog::new().with_auto_scroll(false);
log.push_text("This is a very long line that should be truncated to fit");
let mut buf = ScreenBuffer::new(Size::new(10, 1));
log.render(Rect::new(0, 0, 10, 1), &mut buf);
assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("T"));
assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some(" "));
assert_eq!(buf.get(5, 0).map(|c| c.grapheme.as_str()), Some("i"));
}
#[test]
fn unhandled_event_returns_ignored() {
let mut log = RichLog::new();
let event = Event::Key(KeyEvent::plain(KeyCode::Char('a')));
assert_eq!(log.handle_event(&event), EventResult::Ignored);
}
#[test]
fn builder_pattern() {
let log = RichLog::new()
.with_style(Style::new().bold(true))
.with_border(BorderStyle::Rounded)
.with_auto_scroll(false);
assert!(!log.auto_scroll);
assert!(matches!(log.border, BorderStyle::Rounded));
}
}