use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::Rect;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shift {
pub top: u16,
pub h: u16,
pub k: u16,
}
#[derive(Default)]
pub struct Scroller {
prev: Option<Buffer>,
}
pub type Cells = Vec<(u16, u16, Cell)>;
impl Scroller {
pub fn observe(&mut self, next: &Buffer, body: Option<Rect>) -> Option<(Shift, Cells)> {
let plan = match (&self.prev, body) {
(Some(prev), Some(body)) => plan(prev, next, body),
_ => None,
};
self.prev = body.map(|_| next.clone());
plan
}
}
fn plan(prev: &Buffer, next: &Buffer, body: Rect) -> Option<(Shift, Cells)> {
let mut best: Option<(Shift, Cells)> = None;
for s in candidates(prev, next, body) {
let cells: Cells = shifted(prev, s)
.diff(next)
.into_iter()
.map(|(x, y, c)| (x, y, c.clone()))
.collect();
if best.as_ref().is_none_or(|(_, b)| cells.len() < b.len()) {
best = Some((s, cells));
}
}
let (s, cells) = best?;
(cells.len() < prev.diff(next).len()).then_some((s, cells))
}
fn candidates(prev: &Buffer, next: &Buffer, body: Rect) -> Vec<Shift> {
let area = next.area;
if prev.area != area
|| area.x != 0
|| area.y != 0
|| body.x != 0
|| body.width != area.width
|| body.height < 3
|| body.bottom() > area.bottom()
{
return Vec::new();
}
let w = usize::from(area.width);
fn row(b: &Buffer, y: u16, w: usize) -> &[Cell] {
let at = usize::from(y) * w;
&b.content[at..at + w]
}
(1..=body.height / 2)
.filter(|&k| {
let kept = body.height - k;
let run = (0..kept)
.rev()
.take_while(|&i| row(next, body.y + i, w) == row(prev, body.y + i + k, w))
.count();
run >= 2.min(usize::from(kept))
})
.map(|k| Shift {
top: body.y,
h: body.height,
k,
})
.collect()
}
fn shifted(prev: &Buffer, s: Shift) -> Buffer {
let w = usize::from(prev.area.width);
let mut b = prev.clone();
let at = |y: u16| usize::from(y - prev.area.y) * w;
for i in 0..s.h {
let to = at(s.top + i);
if i + s.k < s.h {
let from = at(s.top + i + s.k);
b.content[to..to + w].clone_from_slice(&prev.content[from..from + w]);
} else {
b.content[to..to + w].fill(Cell::default());
}
}
b
}
#[cfg(test)]
mod tests {
use super::*;
const W: u16 = 12;
const H: u16 = 10;
struct Rng(u64);
impl Rng {
fn next(&mut self, n: u64) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) % n
}
}
fn screen(body: &[String]) -> Buffer {
let mut b = Buffer::empty(Rect::new(0, 0, W, H));
b.set_string(0, 0, "HEAD", ratatui::style::Style::default());
for (i, line) in body.iter().enumerate() {
b.set_string(0, 1 + i as u16, line, ratatui::style::Style::default());
}
b.set_string(0, H - 1, "FOOT", ratatui::style::Style::default());
b
}
const BODY: Rect = Rect::new(0, 1, W, H - 2);
fn apply(screen: &mut Buffer, s: Option<Shift>, cells: &Cells) {
if let Some(s) = s {
*screen = shifted(screen, s);
}
for (x, y, c) in cells {
screen[(*x, *y)] = c.clone();
}
}
#[test]
fn new_messages_scroll_and_match_a_full_redraw() {
let mut rng = Rng(7);
let rows = usize::from(BODY.height);
let mut log: Vec<String> = Vec::new();
let mut sc = Scroller::default();
let mut term = Buffer::empty(Rect::new(0, 0, W, H));
let mut scrolled = 0;
for n in 0..400 {
match rng.next(6) {
0 if !log.is_empty() => {
let i = rng.next(log.len() as u64) as usize;
log[i] = format!("edit{n}");
}
1 => {}
_ => {
for _ in 0..=rng.next(3) {
log.push(format!("m{n}-{}", rng.next(1000)));
}
}
}
let mut tail: Vec<String> = log[log.len().saturating_sub(rows)..].to_vec();
if rng.next(3) == 0 {
tail[0].clear(); }
let next = screen(&tail);
let before = term.clone();
match sc.observe(&next, Some(BODY)) {
Some((s, cells)) => {
scrolled += 1;
apply(&mut term, Some(s), &cells);
}
None => {
let cells: Cells = before
.diff(&next)
.into_iter()
.map(|(x, y, c)| (x, y, c.clone()))
.collect();
apply(&mut term, None, &cells);
}
}
assert_eq!(term, next, "frame {n} diverged from a full redraw");
}
assert!(scrolled > 50, "scroll path never taken ({scrolled})");
}
#[test]
fn region_must_be_full_width_and_unchanged_elsewhere() {
let lines: Vec<String> = (0..9).map(|i| format!("line{i}")).collect();
let a = screen(&lines[..8]);
let b = screen(&lines[1..]);
let mut sc = Scroller::default();
assert!(sc.observe(&a, Some(BODY)).is_none()); let (s, _) = sc.observe(&b, Some(BODY)).expect("shift by one");
assert_eq!((s.top, s.h, s.k), (1, H - 2, 1));
let mut sc = Scroller::default();
sc.observe(&a, Some(Rect::new(0, 1, W - 2, H - 2)));
assert!(sc
.observe(&b, Some(Rect::new(0, 1, W - 2, H - 2)))
.is_none());
let mut sc = Scroller::default();
sc.observe(&a, None);
assert!(sc.observe(&b, None).is_none());
}
}