use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
pub const DEFAULT_CAPACITY: usize = 500;
struct Inner {
lines: VecDeque<String>,
capacity: usize,
pending: String,
}
#[derive(Clone)]
pub struct ConsoleLog {
inner: Arc<Mutex<Inner>>,
}
impl ConsoleLog {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
capacity: capacity.max(1),
pending: String::new(),
})),
}
}
pub fn line(&self, text: impl AsRef<str>) {
let mut inner = self.lock();
for (i, part) in text.as_ref().split('\n').enumerate() {
if i > 0 {
Self::commit_pending(&mut inner);
}
inner.pending.push_str(part);
}
Self::commit_pending(&mut inner);
}
pub fn clear(&self) {
let mut inner = self.lock();
inner.lines.clear();
inner.pending.clear();
}
pub fn len(&self) -> usize {
self.lock().lines.len()
}
pub fn is_empty(&self) -> bool {
self.lock().lines.is_empty()
}
pub fn snapshot(&self) -> Vec<String> {
self.lock().lines.iter().cloned().collect()
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
fn commit_pending(inner: &mut Inner) {
let mut line = std::mem::take(&mut inner.pending);
if line.ends_with('\r') {
line.pop();
}
inner.lines.push_back(line);
while inner.lines.len() > inner.capacity {
inner.lines.pop_front();
}
}
fn write_bytes(&self, buf: &[u8]) {
let text = String::from_utf8_lossy(buf);
let mut inner = self.lock();
let mut parts = text.split('\n').peekable();
while let Some(part) = parts.next() {
inner.pending.push_str(part);
if parts.peek().is_some() {
Self::commit_pending(&mut inner);
}
}
}
}
impl Default for ConsoleLog {
fn default() -> Self {
Self::new(DEFAULT_CAPACITY)
}
}
impl Write for ConsoleLog {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write_bytes(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Write for &ConsoleLog {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write_bytes(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub struct Console<'a> {
log: &'a ConsoleLog,
title: Option<String>,
}
impl<'a> Console<'a> {
pub fn new(log: &'a ConsoleLog) -> Self {
Self { log, title: None }
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
}
impl View for Console<'_> {
fn measure(&self, available: Size) -> Size {
available
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.is_empty() {
return;
}
let fill = Style::default().fg(ctx.theme.text).bg(ctx.theme.surface);
surface.fill(fill);
let mut body = area;
if let Some(title) = &self.title {
surface.set_string(
area.x,
area.y,
title,
Style::default()
.fg(ctx.theme.accent)
.bg(ctx.theme.surface)
.add_modifier(Modifier::BOLD),
);
body = Rect::new(
area.x,
area.y + 1,
area.width,
area.height.saturating_sub(1),
);
}
if body.is_empty() {
return;
}
let lines = self.log.snapshot();
let visible = body.height as usize;
let start = lines.len().saturating_sub(visible);
for (row, line) in lines[start..].iter().enumerate() {
let y = body.y + row as u16;
surface.set_string(body.x, y, line, fill);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Theme;
use crate::test_support::row;
#[test]
fn line_capture_and_capacity() {
let log = ConsoleLog::new(2);
log.line("one");
log.line("two");
log.line("three");
assert_eq!(log.snapshot(), vec!["two".to_string(), "three".to_string()]);
assert_eq!(log.len(), 2);
}
#[test]
fn write_splits_on_newlines_and_holds_partials() {
let log = ConsoleLog::new(10);
let mut w = log.clone();
write!(w, "hel").unwrap();
write!(w, "lo\nwor").unwrap(); assert_eq!(log.snapshot(), vec!["hello".to_string()]);
writeln!(w, "ld").unwrap(); assert_eq!(
log.snapshot(),
vec!["hello".to_string(), "world".to_string()]
);
writeln!(w, "crlf\r").unwrap();
assert_eq!(log.snapshot().last().unwrap(), "crlf");
}
#[test]
fn shared_handles_write_to_the_same_ring() {
let log = ConsoleLog::new(10);
let a = log.clone();
a.line("from clone");
assert_eq!(log.len(), 1);
log.clear();
assert!(log.is_empty());
}
#[test]
fn console_tails_and_titles() {
let theme = Theme::default();
let log = ConsoleLog::new(100);
for i in 0..10 {
log.line(format!("line{i}"));
}
let view = Console::new(&log).title(" console ");
let buf = crate::testing::render(&view, 20, 4, &theme);
assert!(row(&buf, 0).contains("console"), "title row");
assert!(row(&buf, 1).contains("line7"));
assert!(row(&buf, 3).contains("line9"), "newest at the bottom");
}
#[test]
fn console_empty_and_tiny_do_not_panic() {
let theme = Theme::default();
let log = ConsoleLog::new(10);
for (w, h) in [(0u16, 0u16), (1, 1), (5, 1)] {
let _ = crate::testing::render(&Console::new(&log).title("x"), w, h, &theme);
}
}
}