#![allow(dead_code)]
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use crate::command::RenderBlock;
use crate::tui::render::markdown;
use crate::tui::theme::Theme;
enum Entry {
Block(RenderBlock),
Raw(Vec<Line<'static>>),
Stream(String),
}
const MAX_ENTRIES: usize = 1000;
#[derive(Default)]
pub struct Scrollback {
entries: Vec<Entry>,
scroll: u16,
}
impl Scrollback {
pub fn new() -> Self {
Scrollback::default()
}
fn evict_if_needed(&mut self) {
while self.entries.len() > MAX_ENTRIES {
self.entries.remove(0);
}
}
pub fn push(&mut self, block: RenderBlock) {
self.entries.push(Entry::Block(block));
self.evict_if_needed();
self.scroll = 0;
}
pub fn push_raw(&mut self, lines: Vec<Line<'static>>) {
self.entries.push(Entry::Raw(lines));
self.evict_if_needed();
self.scroll = 0;
}
pub fn push_prompt_echo(&mut self, input: &str) {
self.entries
.push(Entry::Block(RenderBlock::Text(format!("❯ {}", input))));
self.evict_if_needed();
}
pub fn begin_stream(&mut self) {
self.entries.push(Entry::Stream(String::new()));
self.evict_if_needed();
self.scroll = 0;
}
pub fn stream_line(&mut self, line: &str) {
match self.entries.last_mut() {
Some(Entry::Stream(buf)) => {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(line);
}
_ => {
self.entries.push(Entry::Stream(line.to_string()));
self.evict_if_needed();
}
}
self.scroll = 0;
}
pub fn clear(&mut self) {
self.entries.clear();
self.scroll = 0;
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_add(n);
}
pub fn scroll_down(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n);
}
pub fn scroll_to_bottom(&mut self) {
self.scroll = 0;
}
pub fn to_lines(&self, theme: &Theme) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for entry in &self.entries {
match entry {
Entry::Raw(raw) => lines.extend(raw.iter().cloned()),
Entry::Stream(s) => {
if s.is_empty() {
continue;
}
let style = Style::default().fg(theme.fg);
for line in s.split('\n') {
lines.push(Line::from(Span::styled(line.to_string(), style)));
}
}
Entry::Block(RenderBlock::Text(s)) => {
let style = if s.starts_with("❯ ") {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
for line in s.split('\n') {
lines.push(Line::from(Span::styled(line.to_string(), style)));
}
}
Entry::Block(RenderBlock::Markdown(md)) => {
let text: Text<'static> = markdown::render(md, theme);
lines.extend(text.lines);
}
Entry::Block(RenderBlock::Error(e)) => {
let style = Style::default().fg(theme.error);
for line in e.split('\n') {
lines.push(Line::from(Span::styled(format!("✗ {}", line), style)));
}
}
}
lines.push(Line::from(""));
}
lines
}
pub fn offset_for(&self, total_lines: usize, height: u16) -> u16 {
let height = height as usize;
if total_lines <= height {
return 0;
}
let max_top = (total_lines - height) as u16;
max_top.saturating_sub(self.scroll)
}
}
#[cfg(test)]
mod cap_tests {
use super::*;
#[test]
fn scrollback_is_capped() {
let mut sb = Scrollback::new();
for i in 0..(MAX_ENTRIES + 100) {
sb.push(RenderBlock::Text(format!("line {}", i)));
}
assert!(
sb.entries.len() <= MAX_ENTRIES,
"scrollback must be capped at MAX_ENTRIES, got {}",
sb.entries.len()
);
assert_eq!(sb.entries.len(), MAX_ENTRIES);
}
}