use turbo_vision::core::draw::{Cell, DrawBuffer};
use turbo_vision::core::event::{
Event, EventType, KB_DOWN, KB_END, KB_HOME, KB_PGDN, KB_PGUP, KB_UP,
};
use turbo_vision::core::geometry::Rect;
use turbo_vision::core::palette::{Attr, TvColor};
use turbo_vision::terminal::Terminal;
use turbo_vision::views::view::{View, write_line_to_terminal};
pub const DEFAULT_MAX_LINES: usize = 10_000;
#[derive(Debug)]
pub struct StreamView {
bounds: Rect,
lines: Vec<Vec<Cell>>,
partial: Option<Vec<Cell>>,
max_lines: usize,
top: usize,
left: usize,
follow: bool,
fill: Attr,
}
impl StreamView {
#[must_use]
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
lines: Vec::new(),
partial: None,
max_lines: DEFAULT_MAX_LINES,
top: 0,
left: 0,
follow: true,
fill: Attr::new(TvColor::LightGray, TvColor::Black),
}
}
pub fn set_max_lines(&mut self, n: usize) {
self.max_lines = n.max(1);
self.trim();
}
pub fn push_line(&mut self, cells: Vec<Cell>) {
self.lines.push(cells);
self.trim();
if self.follow {
self.scroll_to_bottom();
}
}
pub fn set_partial(&mut self, cells: Vec<Cell>) {
self.partial = if cells.is_empty() { None } else { Some(cells) };
if self.follow {
self.scroll_to_bottom();
}
}
pub fn clear(&mut self) {
self.lines.clear();
self.partial = None;
self.top = 0;
self.left = 0;
self.follow = true;
}
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len() + usize::from(self.partial.is_some())
}
fn page(&self) -> usize {
usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
}
fn max_top(&self) -> usize {
self.line_count().saturating_sub(self.page())
}
pub fn scroll_to_bottom(&mut self) {
self.top = self.max_top();
self.follow = true;
}
pub fn scroll_to_top(&mut self) {
self.top = 0;
self.follow = false;
}
pub fn scroll_up(&mut self, n: usize) {
self.top = self.top.saturating_sub(n);
self.follow = false;
}
pub fn scroll_down(&mut self, n: usize) {
self.top = (self.top + n).min(self.max_top());
self.follow = self.top == self.max_top();
}
#[must_use]
pub fn is_at_bottom(&self) -> bool {
self.follow
}
#[must_use]
pub fn plain_text(&self) -> String {
let mut out = String::new();
for (i, line) in self.iter_lines().enumerate() {
if i > 0 {
out.push('\n');
}
out.extend(line.iter().map(|c| c.ch));
}
out
}
#[must_use]
pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
self.iter_lines().cloned().collect()
}
fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
self.lines.iter().chain(self.partial.iter())
}
fn trim(&mut self) {
if self.lines.len() > self.max_lines {
let drop = self.lines.len() - self.max_lines;
self.lines.drain(..drop);
self.top = self.top.saturating_sub(drop);
}
}
}
impl View for StreamView {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
if self.follow {
self.scroll_to_bottom();
} else {
self.top = self.top.min(self.max_top());
}
}
fn draw(&mut self, terminal: &mut Terminal) {
if self.bounds.height() <= 0 {
return;
}
let width = usize::try_from(self.bounds.width()).unwrap_or(0);
let page = self.page();
let lines: Vec<&Vec<Cell>> = self.iter_lines().skip(self.top).take(page).collect();
for row in 0..page {
let mut buf = DrawBuffer::new(width);
for i in 0..width {
buf.put_char(i, ' ', self.fill);
}
if let Some(line) = lines.get(row) {
for (i, cell) in line.iter().skip(self.left).take(width).enumerate() {
buf.put_char(i, cell.ch, cell.attr);
}
}
let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
}
}
fn handle_event(&mut self, event: &mut Event) {
if event.what != EventType::Keyboard {
return;
}
let page = self.page();
match event.key_code {
KB_UP => self.scroll_up(1),
KB_DOWN => self.scroll_down(1),
KB_PGUP => self.scroll_up(page),
KB_PGDN => self.scroll_down(page),
KB_HOME => self.scroll_to_top(),
KB_END => self.scroll_to_bottom(),
_ => return,
}
event.clear();
}
fn can_focus(&self) -> bool {
true
}
fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::time::Duration;
use turbo_vision::core::palette::TvColor;
use turbo_vision::terminal::Backend;
fn line(s: &str) -> Vec<Cell> {
s.chars()
.map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
.collect()
}
fn view() -> StreamView {
StreamView::new(Rect::new(0, 0, 40, 10))
}
struct FakeBackend {
width: u16,
height: u16,
}
impl Backend for FakeBackend {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn init(&mut self) -> io::Result<()> {
Ok(())
}
fn cleanup(&mut self) -> io::Result<()> {
Ok(())
}
fn size(&self) -> io::Result<(u16, u16)> {
Ok((self.width, self.height))
}
fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
}
fn fake_terminal(width: u16, height: u16) -> Terminal {
Terminal::with_backend(Box::new(FakeBackend { width, height }))
.expect("fake backend never fails to init")
}
#[test]
fn scrollback_cap_drops_oldest_lines() {
let mut v = view();
v.set_max_lines(3);
for i in 0..5 {
v.push_line(line(&i.to_string()));
}
assert_eq!(v.line_count(), 3);
assert_eq!(v.plain_text(), "2\n3\n4");
}
#[test]
fn autoscroll_holds_at_bottom_while_lines_arrive() {
let mut v = view();
for i in 0..50 {
v.push_line(line(&i.to_string()));
}
assert!(v.is_at_bottom());
}
#[test]
fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
let mut v = view();
for i in 0..50 {
v.push_line(line(&i.to_string()));
}
v.scroll_up(5);
assert!(!v.is_at_bottom());
v.push_line(line("new"));
assert!(
!v.is_at_bottom(),
"a new line must not yank a scrolled-back reader to the bottom"
);
v.scroll_to_bottom();
assert!(v.is_at_bottom());
}
#[test]
fn partial_line_is_replaced_not_appended() {
let mut v = view();
v.set_partial(line("par"));
v.set_partial(line("part"));
assert_eq!(v.plain_text(), "part");
assert_eq!(v.line_count(), 1);
}
#[test]
fn plain_text_strips_attributes() {
let mut v = view();
v.push_line(vec![Cell::new(
'x',
Attr::new(TvColor::LightRed, TvColor::Blue),
)]);
assert_eq!(v.plain_text(), "x");
}
#[test]
fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
for i in 0..50 {
v.push_line(line(&i.to_string()));
}
v.scroll_to_top();
v.scroll_down(40);
assert!(!v.is_at_bottom());
let old_top = v.top;
assert!(old_top < v.max_top());
v.set_bounds(Rect::new(0, 0, 40, 48));
assert!(
v.top <= v.max_top(),
"top ({}) must not exceed max_top ({}) after growing",
v.top,
v.max_top()
);
let lines: Vec<&Vec<Cell>> = v.iter_lines().skip(v.top).take(v.page()).collect();
assert_eq!(
lines.len(),
v.page().min(v.line_count()),
"a full page of content should be visible after growing"
);
}
#[test]
fn draw_clips_to_bounds_and_applies_horizontal_offset() {
let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
v.push_line(line("abcdefghij")); v.push_line(line("short")); v.left = 2;
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
for (i, expected) in "cdefgh".chars().enumerate() {
let cell = terminal
.read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
.expect("cell within terminal bounds");
assert_eq!(cell.ch, expected);
}
assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
for (i, expected) in "ort ".chars().enumerate() {
let cell = terminal
.read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 2)
.expect("cell within terminal bounds");
assert_eq!(cell.ch, expected);
}
assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
}
#[test]
fn draw_on_zero_height_view_writes_nothing() {
let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
v.push_line(line("hello"));
let mut terminal = fake_terminal(20, 10);
v.draw(&mut terminal);
for y in 0..10 {
for x in 0..20 {
assert_eq!(
terminal.read_cell(x, y).unwrap().ch,
' ',
"zero-height view must not write any cell"
);
}
}
}
}