pub struct Scrollback {
lines: Vec<String>,
max_lines: usize,
scroll_offset: usize,
}
impl Scrollback {
pub fn new(max_lines: usize) -> Self {
Self {
lines: Vec::with_capacity(max_lines.min(1024)),
max_lines,
scroll_offset: 0,
}
}
pub fn push(&mut self, line: String) {
if self.lines.len() >= self.max_lines {
self.lines.remove(0);
}
self.lines.push(line);
}
pub fn push_str(&mut self, text: &str) {
for line in text.lines() {
self.push(line.to_string());
}
if text.is_empty() {
return;
}
let last_byte = text.as_bytes().last().copied().unwrap_or(b'\n');
if last_byte == b'\n' {
self.push(String::new());
}
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
pub fn scroll_up(&mut self, n: usize) -> usize {
self.scroll_offset = (self.scroll_offset + n).min(self.max_scroll());
self.scroll_offset
}
pub fn scroll_down(&mut self, n: usize) -> usize {
self.scroll_offset = self.scroll_offset.saturating_sub(n);
self.scroll_offset
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = self.max_scroll();
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn is_at_bottom(&self) -> bool {
self.scroll_offset == 0
}
fn max_scroll(&self) -> usize {
self.lines.len().saturating_sub(1)
}
pub fn visible(&self, viewport_height: usize) -> (&[String], usize, usize) {
if self.lines.is_empty() {
return (&[], 0, 0);
}
let total = self.lines.len();
let scroll = self.scroll_offset.min(self.max_scroll());
let end = total.saturating_sub(scroll);
let start = end.saturating_sub(viewport_height);
(&self.lines[start..end], start, total)
}
}
impl Default for Scrollback {
fn default() -> Self {
Self::new(10_000)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_scrollback() {
let sb = Scrollback::new(100);
assert!(sb.is_empty());
assert_eq!(sb.len(), 0);
assert!(sb.is_at_bottom());
}
#[test]
fn push_and_retrieve() {
let mut sb = Scrollback::new(100);
sb.push("line 1".to_string());
sb.push("line 2".to_string());
assert_eq!(sb.len(), 2);
assert!(!sb.is_empty());
}
#[test]
fn push_str_splits_lines() {
let mut sb = Scrollback::new(100);
sb.push_str("line 1\nline 2\n");
assert_eq!(sb.len(), 3); }
#[test]
fn scroll_offset_tracking() {
let mut sb = Scrollback::new(100);
for i in 0..50 {
sb.push(format!("line {i}"));
}
assert!(sb.is_at_bottom());
assert_eq!(sb.scroll_offset(), 0);
sb.scroll_up(5);
assert_eq!(sb.scroll_offset(), 5);
assert!(!sb.is_at_bottom());
sb.scroll_down(2);
assert_eq!(sb.scroll_offset(), 3);
sb.scroll_to_bottom();
assert_eq!(sb.scroll_offset(), 0);
sb.scroll_to_top();
assert_eq!(sb.scroll_offset(), sb.max_scroll());
}
#[test]
fn visible_returns_correct_subset() {
let mut sb = Scrollback::new(100);
for i in 0..50 {
sb.push(format!("line {i}"));
}
let (visible, start, total) = sb.visible(10);
assert_eq!(visible.len(), 10);
assert_eq!(total, 50);
assert!(visible[0].starts_with("line 4"));
assert!(visible[9].starts_with("line 49"));
sb.scroll_up(5);
let (visible, start, _) = sb.visible(10);
assert_eq!(start, 35);
assert!(visible[0].starts_with("line 35"));
}
#[test]
fn max_limit_eviction() {
let mut sb = Scrollback::new(5);
for i in 0..10 {
sb.push(format!("line {i}"));
}
assert_eq!(sb.len(), 5);
assert_eq!(
sb.visible(10)
.0
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>(),
vec!["line 5", "line 6", "line 7", "line 8", "line 9"]
);
}
#[test]
fn scroll_boundaries() {
let mut sb = Scrollback::new(100);
for i in 0..20 {
sb.push(format!("line {i}"));
}
sb.scroll_up(100);
assert_eq!(sb.scroll_offset(), sb.max_scroll());
sb.scroll_down(100);
assert_eq!(sb.scroll_offset(), 0);
}
#[test]
fn visible_with_smaller_than_viewport() {
let mut sb = Scrollback::new(100);
sb.push("only line".to_string());
let (visible, _, total) = sb.visible(10);
assert_eq!(visible.len(), 1);
assert_eq!(total, 1);
}
#[test]
fn push_str_without_trailing_newline() {
let mut sb = Scrollback::new(100);
sb.push_str("hello\nworld");
assert_eq!(sb.len(), 2);
let (visible, _, _) = sb.visible(10);
assert_eq!(visible[0], "hello");
assert_eq!(visible[1], "world");
}
#[test]
fn push_str_empty_does_nothing() {
let mut sb = Scrollback::new(100);
sb.push_str("");
assert_eq!(sb.len(), 0);
}
#[test]
fn push_str_with_only_newline() {
let mut sb = Scrollback::new(100);
sb.push_str("\n");
assert_eq!(sb.len(), 2);
}
#[test]
fn scroll_offset_clamped_after_eviction() {
let mut sb = Scrollback::new(5);
for i in 0..5 {
sb.push(format!("line {i}"));
}
sb.scroll_up(3);
assert_eq!(sb.scroll_offset(), 3);
for i in 5..10 {
sb.push(format!("line {i}"));
}
assert_eq!(sb.scroll_offset(), 3);
}
}