use std::num::NonZeroUsize;
use std::ops::Range;
use ratatui::buffer::Buffer;
use ratatui::layout::{Rect, Size};
use ratatui::widgets::Widget;
type Milliseconds = usize;
pub struct TickEvent {
pub dt: Milliseconds,
}
pub trait EventListener<Event> {
fn on_event(&mut self, context: &Event);
}
pub trait Animation {
fn size(&self) -> Size;
fn paint(&self, buffer: &mut Buffer);
fn tick(&mut self);
}
pub struct Animated<T: Animation> {
content: T,
buffer: Buffer,
period: NonZeroUsize,
accumulator: Milliseconds,
}
impl<T: Animation> Animated<T> {
pub fn new(content: T, period: NonZeroUsize) -> Self {
let mut animated = Self {
content,
buffer: Buffer::empty(Rect::ZERO),
period,
accumulator: 0,
};
animated.redraw();
animated
}
pub fn buffer(&self) -> &Buffer {
&self.buffer
}
pub fn size(&self) -> Size {
self.buffer.area().as_size()
}
pub fn height(&self) -> u16 {
self.size().height
}
fn redraw(&mut self) {
let size = self.content.size();
self.buffer = Buffer::empty(Rect::new(0, 0, size.width, size.height));
self.content.paint(&mut self.buffer);
}
}
impl<T: Animation> EventListener<TickEvent> for Animated<T> {
fn on_event(&mut self, tick: &TickEvent) {
self.accumulator += tick.dt;
while self.accumulator >= self.period.get() {
self.accumulator -= self.period.get();
self.content.tick();
}
self.redraw();
}
}
pub fn paste(child: &Buffer, rows: Range<u16>, into: Rect, parent: &mut Buffer) {
let source = *child.area();
let clip = into.intersection(*parent.area());
let width = usize::from(source.width.min(clip.width));
if clip.is_empty() || width == 0 {
return;
}
for (offset, row) in (rows.start..rows.end.min(source.height)).enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
if offset >= clip.height {
break;
}
let from = child.index_of(source.x, source.y + row);
let to = parent.index_of(clip.x, clip.y + offset);
parent.content[to..to + width].clone_from_slice(&child.content[from..from + width]);
}
}
impl<T: Animation> Widget for &Animated<T> {
fn render(self, area: Rect, buf: &mut Buffer) {
paste(self.buffer(), 0..self.height(), area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Filling {
width: u16,
filled: u16,
}
impl Animation for Filling {
fn size(&self) -> Size {
Size::new(self.width, 1)
}
fn paint(&self, buffer: &mut Buffer) {
for x in 0..self.width {
buffer[(x, 0)].set_char(if x < self.filled { 'X' } else { '.' });
}
}
fn tick(&mut self) {
self.filled = self.filled.saturating_add(1).min(self.width);
}
}
struct Spinning {
frames: Vec<char>,
at: usize,
}
impl Animation for Spinning {
fn size(&self) -> Size {
Size::new(1, 1)
}
fn paint(&self, buffer: &mut Buffer) {
buffer[(0, 0)].set_char(self.frames[self.at]);
}
fn tick(&mut self) {
self.at = (self.at + 1) % self.frames.len();
}
}
fn period(milliseconds: usize) -> NonZeroUsize {
NonZeroUsize::new(milliseconds).expect("test periods are positive")
}
fn filling() -> Animated<Filling> {
Animated::new(
Filling {
width: 4,
filled: 0,
},
period(100),
)
}
fn row(animated: &Animated<impl Animation>, width: u16) -> String {
(0..width)
.map(|x| animated.buffer()[(x, 0)].symbol())
.collect()
}
#[test]
fn one_action_and_another_give_two_animations_and_no_other_code() {
let mut bar = filling();
let mut spinner = Animated::new(
Spinning {
frames: vec!['|', '/', '-'],
at: 0,
},
period(100),
);
bar.on_event(&TickEvent { dt: 200 });
spinner.on_event(&TickEvent { dt: 200 });
assert_eq!(row(&bar, 4), "XX..");
assert_eq!(row(&spinner, 1), "-");
}
#[test]
fn elapsed_time_acts_the_same_split_as_whole() {
let mut split = filling();
let mut whole = filling();
for _ in 0..3 {
split.on_event(&TickEvent { dt: 100 });
}
whole.on_event(&TickEvent { dt: 300 });
assert_eq!(row(&split, 4), row(&whole, 4));
assert_eq!(row(&whole, 4), "XXX.");
}
#[test]
fn a_remainder_below_the_period_carries_rather_than_vanishing() {
let mut carried = filling();
for _ in 0..3 {
carried.on_event(&TickEvent { dt: 60 });
}
assert_eq!(row(&carried, 4), "X...");
carried.on_event(&TickEvent { dt: 20 });
assert_eq!(row(&carried, 4), "XX..");
}
#[test]
fn time_below_one_period_changes_nothing() {
let mut idle = filling();
idle.on_event(&TickEvent { dt: 0 });
idle.on_event(&TickEvent { dt: 99 });
assert_eq!(row(&idle, 4), "....");
}
fn marked(width: u16, height: u16) -> Buffer {
let mut cells = Buffer::empty(Rect::new(0, 0, width, height));
for y in 0..height {
cells[(0, y)].set_symbol(&y.to_string());
}
cells
}
#[test]
fn a_settled_element_renders_as_a_widget_clipped_to_its_area() {
let mut bar = filling();
bar.on_event(&TickEvent { dt: 400 });
let mut screen = Buffer::empty(Rect::new(0, 0, 8, 2));
(&bar).render(Rect::new(1, 1, 2, 1), &mut screen);
let placed: String = (0..8).map(|x| screen[(x, 1)].symbol()).collect();
assert_eq!(placed, " XX ");
assert!((0..8).all(|x| screen[(x, 0)].symbol() == " "));
}
#[test]
fn pasting_shifts_a_child_slice_without_wrapping_past_the_parent() {
let child = marked(2, 6);
let mut parent = Buffer::empty(Rect::new(0, 0, 4, 3));
paste(&child, 2..5, Rect::new(1, 0, 2, 3), &mut parent);
assert_eq!(parent[(1, 0)].symbol(), "2");
assert_eq!(parent[(1, 2)].symbol(), "4");
assert_eq!(parent[(0, 0)].symbol(), " ");
}
#[test]
fn pasting_drops_rows_and_columns_beyond_the_parent() {
let child = marked(6, 4);
let mut parent = Buffer::empty(Rect::new(0, 0, 4, 2));
paste(&child, 0..4, Rect::new(2, 1, 6, 4), &mut parent);
assert_eq!(parent[(2, 1)].symbol(), "0");
assert_eq!(parent[(2, 0)].symbol(), " ");
}
}