use std::io;
use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use ratatui::Frame;
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, BorderType, Borders, LineGauge, Paragraph};
use serde::Deserialize;
const DEMO_CAST: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/demo.cast"));
const FRAME_POLL: Duration = Duration::from_millis(33);
#[derive(Deserialize)]
struct CastHeader {
version: u8,
width: u16,
height: u16,
}
struct Cast {
width: u16,
height: u16,
events: Vec<CastEvent>,
}
struct CastEvent {
time: f64,
data: String,
}
fn parse_cast(bytes: &[u8]) -> anyhow::Result<Cast> {
let text = std::str::from_utf8(bytes).context("data is corrupted")?;
let mut lines = text.lines();
let header_line = lines.next().context("demo is empty")?;
let header: CastHeader = serde_json::from_str(header_line).context("header is malformed")?;
if header.version != 2 {
return Err(anyhow!("unsupported version {} (need v2)", header.version));
}
let mut events = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(line).context("frame is malformed")?;
let Some(arr) = value.as_array() else {
continue;
};
if arr.len() < 3 {
continue;
}
let Some(time) = arr[0].as_f64() else {
continue;
};
if arr[1].as_str() != Some("o") {
continue;
}
let Some(data) = arr[2].as_str() else {
continue;
};
events.push(CastEvent {
time,
data: data.to_string(),
});
}
Ok(Cast {
width: header.width,
height: header.height,
events,
})
}
pub fn run() -> anyhow::Result<()> {
let cast = parse_cast(DEMO_CAST).context("couldn't load demo")?;
let (term_cols, term_rows) = ratatui::crossterm::terminal::size()?;
const MIN_RATIO_FRAC: f64 = 0.50;
let cast_ratio = cast.width as f64 / cast.height as f64;
let term_ratio = term_cols as f64 / term_rows as f64;
let threshold = cast_ratio * MIN_RATIO_FRAC;
if term_ratio < threshold {
let min_cols = (threshold * term_rows as f64).ceil() as u16;
let max_rows = (term_cols as f64 / threshold).floor() as u16;
print_aspect_mismatch_error(
term_cols,
term_rows,
cast.width,
cast.height,
min_cols,
max_rows,
);
std::process::exit(2);
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = playback_loop(&mut terminal, &cast);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
match result? {
ExitReason::UserQuit => Ok(()),
ExitReason::AspectChanged {
cols,
rows,
min_cols,
max_rows,
} => {
print_aspect_mismatch_error(cols, rows, cast.width, cast.height, min_cols, max_rows);
std::process::exit(2);
}
}
}
enum ExitReason {
UserQuit,
AspectChanged {
cols: u16,
rows: u16,
min_cols: u16,
max_rows: u16,
},
}
fn playback_loop<B: Backend>(
terminal: &mut Terminal<B>,
cast: &Cast,
) -> anyhow::Result<ExitReason> {
let mut vt = avt::Vt::new(cast.width as usize, cast.height as usize);
let mut next_event = 0usize;
let total = cast.events.last().map(|e| e.time).unwrap_or(0.0);
let cast_ratio = cast.width as f64 / cast.height as f64;
const MIN_RATIO_FRAC: f64 = 0.50;
let ratio_threshold = cast_ratio * MIN_RATIO_FRAC;
let mut start = Instant::now();
let mut paused = false;
let mut pause_start: Option<Instant> = None;
let mut paused_offset = Duration::ZERO;
let mut last_play_at: Option<Instant> = Some(Instant::now());
const PLAY_FLASH: Duration = Duration::from_millis(500);
loop {
if let Ok((cols, rows)) = ratatui::crossterm::terminal::size() {
let term_ratio = cols as f64 / rows as f64;
if term_ratio < ratio_threshold {
let min_cols = (ratio_threshold * rows as f64).ceil() as u16;
let max_rows = (cols as f64 / ratio_threshold).floor() as u16;
return Ok(ExitReason::AspectChanged {
cols,
rows,
min_cols,
max_rows,
});
}
}
let elapsed_dur = if let Some(ps) = pause_start {
ps.saturating_duration_since(start)
.saturating_sub(paused_offset)
} else {
Instant::now()
.saturating_duration_since(start)
.saturating_sub(paused_offset)
};
let elapsed = elapsed_dur.as_secs_f64();
while next_event < cast.events.len() && cast.events[next_event].time <= elapsed {
vt.feed_str(&cast.events[next_event].data);
next_event += 1;
}
let done = next_event >= cast.events.len() && elapsed >= total;
let show_play = !paused
&& last_play_at
.map(|t| t.elapsed() < PLAY_FLASH)
.unwrap_or(false);
terminal.draw(|f| draw_player(f, &vt, elapsed, total, paused, done, show_play))?;
if event::poll(FRAME_POLL)?
&& let Event::Key(k) = event::read()?
&& k.kind != KeyEventKind::Release
{
match k.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(ExitReason::UserQuit),
KeyCode::Char(' ') => {
if paused {
if let Some(ps) = pause_start.take() {
paused_offset += ps.elapsed();
}
paused = false;
last_play_at = Some(Instant::now());
} else {
pause_start = Some(Instant::now());
paused = true;
}
}
KeyCode::Char('r') => {
vt = avt::Vt::new(cast.width as usize, cast.height as usize);
next_event = 0;
start = Instant::now();
paused = false;
pause_start = None;
paused_offset = Duration::ZERO;
last_play_at = Some(Instant::now());
}
_ => {}
}
}
}
}
fn draw_player(
f: &mut Frame<'_>,
vt: &avt::Vt,
elapsed: f64,
total: f64,
paused: bool,
done: bool,
show_play: bool,
) {
let area = f.area();
let icon = if done {
"■"
} else if paused {
"⏸"
} else {
"▶"
};
let title = format!(" {icon} sidekick demo ");
let hints = " [space] pause · [r] restart · [q] quit ";
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.title(title)
.title_bottom(Line::from(hints).right_aligned());
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(inner);
render_vt(f.buffer_mut(), chunks[0], vt);
if paused {
render_pause_overlay(f.buffer_mut(), chunks[0]);
} else if show_play {
render_play_overlay(f.buffer_mut(), chunks[0]);
}
render_status(f, chunks[1], elapsed, total, paused);
}
fn render_play_overlay(buf: &mut Buffer, area: Rect) {
const ICON_H: u16 = 10;
let half = ICON_H / 2;
let icon_w = half;
if area.width < icon_w || area.height < ICON_H {
return;
}
let x0 = area.x + (area.width - icon_w) / 2;
let y0 = area.y + (area.height - ICON_H) / 2;
let style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
for row in 0..ICON_H {
let (dist, glyph) = if row < half {
(half - 1 - row, "◣")
} else {
(row - half, "◤")
};
let body_w = half - 1 - dist;
for col in 0..body_w {
let pos = Position {
x: x0 + col,
y: y0 + row,
};
if let Some(c) = buf.cell_mut(pos) {
c.set_symbol("█");
c.set_style(style);
}
}
let pos = Position {
x: x0 + body_w,
y: y0 + row,
};
if let Some(c) = buf.cell_mut(pos) {
c.set_symbol(glyph);
c.set_style(style);
}
}
}
fn render_pause_overlay(buf: &mut Buffer, area: Rect) {
const BAR_W: u16 = 5;
const GAP_W: u16 = 4;
const ICON_H: u16 = 8;
let icon_w = BAR_W * 2 + GAP_W;
if area.width < icon_w || area.height < ICON_H {
return;
}
let x0 = area.x + (area.width - icon_w) / 2;
let y0 = area.y + (area.height - ICON_H) / 2;
let style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
for row in 0..ICON_H {
for col in 0..icon_w {
if (BAR_W..BAR_W + GAP_W).contains(&col) {
continue;
}
let pos = Position {
x: x0 + col,
y: y0 + row,
};
if let Some(c) = buf.cell_mut(pos) {
c.set_symbol("█");
c.set_style(style);
}
}
}
}
fn render_vt(buf: &mut Buffer, area: Rect, vt: &avt::Vt) {
for (y, line) in vt.view().enumerate() {
let row = y as u16;
if row >= area.height {
break;
}
for (x, cell) in line.cells().iter().enumerate() {
let col = x as u16;
if col >= area.width {
break;
}
if cell.width() == 0 {
continue;
}
let pos = Position {
x: area.x + col,
y: area.y + row,
};
if let Some(buf_cell) = buf.cell_mut(pos) {
let mut s = [0u8; 4];
let symbol = cell.char().encode_utf8(&mut s);
buf_cell.set_symbol(symbol);
buf_cell.set_style(pen_to_style(cell.pen()));
}
}
}
let cursor = vt.cursor();
if cursor.visible {
let col = cursor.col as u16;
let row = cursor.row as u16;
if col < area.width && row < area.height {
let pos = Position {
x: area.x + col,
y: area.y + row,
};
if let Some(buf_cell) = buf.cell_mut(pos) {
buf_cell.set_style(buf_cell.style().add_modifier(Modifier::REVERSED));
}
}
}
}
fn pen_to_style(pen: &avt::Pen) -> Style {
let mut style = Style::default();
if let Some(fg) = pen.foreground() {
style = style.fg(map_color(fg));
}
if let Some(bg) = pen.background() {
style = style.bg(map_color(bg));
}
let mut mods = Modifier::empty();
if pen.is_bold() {
mods |= Modifier::BOLD;
}
if pen.is_italic() {
mods |= Modifier::ITALIC;
}
if pen.is_underline() {
mods |= Modifier::UNDERLINED;
}
if pen.is_inverse() {
mods |= Modifier::REVERSED;
}
if pen.is_faint() {
mods |= Modifier::DIM;
}
if pen.is_blink() {
mods |= Modifier::SLOW_BLINK;
}
if pen.is_strikethrough() {
mods |= Modifier::CROSSED_OUT;
}
style.add_modifier(mods)
}
fn map_color(c: avt::Color) -> Color {
match c {
avt::Color::Indexed(n) => Color::Indexed(n),
avt::Color::RGB(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b),
}
}
fn render_status(f: &mut Frame<'_>, area: Rect, elapsed: f64, total: f64, paused: bool) {
let clamped = elapsed.clamp(0.0, total.max(0.001));
let time_text = format!("{} / {}", format_time(clamped), format_time(total));
let prefix = if paused { " ⏸ " } else { " ▶ " };
let time_w = time_text.len() as u16 + 2;
let prefix_w = 4u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(prefix_w),
Constraint::Min(1),
Constraint::Length(time_w),
])
.split(area);
f.render_widget(
Paragraph::new(prefix).style(Style::default().fg(Color::Cyan)),
chunks[0],
);
let ratio = if total > 0.0 { clamped / total } else { 0.0 };
let gauge = LineGauge::default()
.filled_style(Style::default().fg(Color::Cyan))
.unfilled_style(Style::default().fg(Color::DarkGray))
.ratio(ratio.clamp(0.0, 1.0));
f.render_widget(gauge, chunks[1]);
f.render_widget(
Paragraph::new(time_text)
.style(Style::default().fg(Color::DarkGray))
.right_aligned(),
chunks[2],
);
}
fn format_time(secs: f64) -> String {
let s = secs.max(0.0) as u64;
format!("{:02}:{:02}", s / 60, s % 60)
}
fn print_aspect_mismatch_error(
have_w: u16,
have_h: u16,
cast_w: u16,
cast_h: u16,
min_cols: u16,
max_rows: u16,
) {
let red = "\x1b[31m";
let cyan = "\x1b[36m";
let dim = "\x1b[2m";
let bold = "\x1b[1m";
let reset = "\x1b[0m";
eprintln!();
eprintln!(" {cyan}▌{reset} {bold}sidekick demo{reset} {dim}⏹ can't play{reset}");
eprintln!();
eprintln!(" {red}⚠{reset} {bold}Your terminal is the wrong shape for this demo.{reset}");
eprintln!();
eprintln!(" {dim}your terminal:{reset} {bold}{have_w}×{have_h}{reset}");
eprintln!(" {dim}demo:{reset} {bold}{cast_w}×{cast_h}{reset}");
eprintln!();
eprintln!(
" {dim}Widen to at least{reset} {bold}{min_cols}{reset} {dim}cols{reset}{dim}, or shorten to{reset} {bold}{max_rows}{reset} {dim}rows.{reset}"
);
eprintln!();
}