mod animations;
mod config;
mod external;
mod gallery;
pub mod generators;
mod gif;
mod png;
mod record;
mod render;
#[cfg(unix)]
mod render_sink;
use animations::Animation;
use clap::Parser;
use crossterm::{
cursor,
event::{
self, DisableFocusChange, EnableFocusChange, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
},
execute, terminal,
};
use external::{CurrentState, ExternalParams, ParamsSource, spawn_reader};
use render::{Canvas, ColorAssist, ColorMode, PostProcessConfig, RenderMode, smoothing_alpha};
use std::io;
use std::io::IsTerminal;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Parser)]
#[command(name = "termflix", about = "Terminal animation player")]
struct Cli {
animation: Option<String>,
#[arg(short, long, value_enum)]
render: Option<RenderMode>,
#[arg(short, long, value_enum)]
color: Option<ColorMode>,
#[arg(short, long)]
fps: Option<u32>,
#[arg(short, long)]
list: Option<Option<String>>,
#[arg(long)]
cycle: Option<u32>,
#[arg(long)]
record: Option<String>,
#[arg(long)]
play: Option<String>,
#[arg(long, value_name = "PATH")]
export_gif: Option<String>,
#[arg(short, long)]
scale: Option<f64>,
#[arg(long)]
unlimited: bool,
#[arg(long)]
clean: bool,
#[arg(long)]
init_config: bool,
#[arg(long)]
show_config: bool,
#[arg(long)]
screensaver: bool,
#[arg(long, requires = "screensaver")]
screensaver_keys: bool,
#[arg(long, value_name = "PATH")]
data_file: Option<String>,
#[arg(long)]
bloom_intensity: Option<f64>,
#[arg(long)]
bloom_threshold: Option<f64>,
#[arg(long)]
vignette: Option<f64>,
#[arg(long)]
scanlines: bool,
#[arg(long)]
smoothing: Option<f64>,
#[arg(long, conflicts_with = "colorblind")]
palette: Option<String>,
#[arg(long)]
colorblind: Option<String>,
#[arg(long)]
dither: bool,
#[arg(long)]
profile: bool,
#[arg(long)]
single_threaded: bool,
#[arg(long)]
full_frames: bool,
#[arg(long)]
gallery: Option<Option<String>>,
#[arg(long)]
gallery_dir: Option<String>,
#[arg(long)]
gallery_cols: Option<usize>,
#[arg(long)]
gallery_rows: Option<usize>,
#[arg(long)]
gallery_wait: Option<f64>,
#[arg(long)]
gallery_duration: Option<f64>,
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
if cli.init_config {
let path = config::config_path().expect("Could not determine config directory");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
println!("Config already exists: {}", path.display());
println!("Delete it first if you want to regenerate.");
} else {
std::fs::write(&path, config::default_config_string())?;
println!("Created config file: {}", path.display());
}
return Ok(());
}
let cfg = config::load_config();
let keybindings = build_keybindings(&cfg);
let data_file = cli.data_file.clone().or(cfg.data_file.clone());
if cli.show_config {
let path = config::config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown)".to_string());
println!("Config file: {}", path);
println!("{:#?}", cfg);
return Ok(());
}
if let Some(ref names) = cli.gallery {
let names = names.as_ref().map(|s| {
s.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect::<Vec<_>>()
});
let config = gallery::GalleryConfig {
dir: cli
.gallery_dir
.unwrap_or_else(|| "./gallery".to_string())
.into(),
cols: cli.gallery_cols.unwrap_or(80),
rows: cli.gallery_rows.unwrap_or(25),
wait_secs: cli.gallery_wait.unwrap_or(3.0),
duration_secs: cli.gallery_duration.unwrap_or(5.0),
names,
};
return gallery::run_gallery(&config);
}
if let Some(ref play_path) = cli.play {
if let Some(ref gif_path) = cli.export_gif {
let player = record::Player::load(play_path)?;
if player.frames().is_empty() {
eprintln!("No frames to export.");
std::process::exit(1);
}
let (cols, rows) = detect_recording_size(player.frames());
let file = std::fs::File::create(gif_path)?;
let mut writer = std::io::BufWriter::new(file);
match gif::export_gif(&mut writer, player.frames(), cols, rows) {
Ok(()) => {
println!("Exported {} frames to {}", player.frames().len(), gif_path);
}
Err(e) => {
eprintln!("GIF export failed: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
let player = record::Player::load(play_path)?;
return player.play();
}
if let Some(filter) = cli.list {
println!("Available animations:");
let filter = filter.as_deref().map(|s| s.to_lowercase());
let mut count = 0;
for &(name, desc) in animations::ANIMATIONS {
if let Some(ref f) = filter
&& !name.to_lowercase().contains(f)
&& !desc.to_lowercase().contains(f)
{
continue;
}
println!(" {:<12} {}", name, desc);
count += 1;
}
if let Some(ref f) = filter {
println!("\n {} animation(s) matching '{}'", count, f);
}
println!("\nRender modes: braille, half-block, ascii");
println!("Color modes: mono, ansi16, ansi256, true-color");
return Ok(());
}
let anim_name = cli
.animation
.clone()
.or(cfg.animation)
.unwrap_or_else(|| "fire".to_string());
let unlimited = cli.unlimited || cfg.unlimited_fps.unwrap_or(false);
let fps = cli.fps.or(cfg.fps).unwrap_or(24).clamp(1, 120);
let frame_dur = if unlimited {
Duration::ZERO
} else {
Duration::from_secs_f64(1.0 / fps as f64)
};
if !animations::ANIMATION_NAMES.contains(&anim_name.as_str()) {
eprintln!(
"Unknown animation: '{}'\n\nAvailable animations:",
anim_name
);
for &(name, desc) in animations::ANIMATIONS {
eprintln!(" {:<12} {}", name, desc);
}
std::process::exit(1);
}
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = terminal::disable_raw_mode();
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = io::stdout().as_raw_fd();
let restore = b"\x1b[?2026l\x1b[?25h\x1b[?1049l";
unsafe {
libc::write(fd, restore.as_ptr() as *const libc::c_void, restore.len());
}
}
#[cfg(not(unix))]
{
let mut stdout = io::stdout();
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
}
default_hook(info);
}));
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)?;
if cli.screensaver {
execute!(stdout, EnableFocusChange)?;
}
let color_mode = cli
.color
.or(cfg.color.map(ColorMode::from))
.unwrap_or(ColorMode::TrueColor);
let scale = cli.scale.or(cfg.scale).unwrap_or(1.0).clamp(0.5, 2.0);
let cycle = cli.cycle.or(cfg.cycle).unwrap_or(0);
let clean = cli.clean || cfg.clean.unwrap_or(false);
let color_quant = cfg.color_quant.unwrap_or(0);
let render_override = cli.render.or(cfg.render.map(RenderMode::from));
let default_bloom = cli
.bloom_intensity
.or(cfg.postproc.and_then(|p| p.bloom))
.unwrap_or(0.4)
.clamp(0.0, 1.0);
let postproc = PostProcessConfig {
bloom: if cli.bloom_intensity.is_some() || cfg.postproc.and_then(|p| p.bloom).is_some() {
default_bloom
} else {
0.0
},
bloom_threshold: cli
.bloom_threshold
.or(cfg.postproc.and_then(|p| p.bloom_threshold))
.unwrap_or(0.6)
.clamp(0.0, 1.0),
vignette: cli
.vignette
.or(cfg.postproc.and_then(|p| p.vignette))
.unwrap_or(0.0)
.clamp(0.0, 1.0),
scanlines: cli.scanlines || cfg.postproc.and_then(|p| p.scanlines).unwrap_or(false),
};
let smoothing_tau = cli
.smoothing
.or(cfg.smoothing)
.map(|v| v.clamp(0.0, 1.0))
.unwrap_or(0.0);
let default_smoothing_tau = cli
.smoothing
.or(cfg.smoothing)
.unwrap_or(0.1)
.clamp(0.0, 1.0);
let assist = ColorAssist::from_cli(
cli.palette.as_deref().or(cfg.palette.as_deref()),
cli.colorblind.as_deref().or(cfg.colorblind.as_deref()),
)
.unwrap_or(ColorAssist::None);
let dither = cli.dither || cfg.dither.unwrap_or(false);
let result = run_loop(
&anim_name,
render_override,
color_mode,
color_quant,
unlimited,
frame_dur,
scale,
cycle,
clean,
cli.screensaver,
cli.screensaver_keys,
cli.record.as_deref(),
data_file,
postproc,
smoothing_tau,
default_smoothing_tau,
default_bloom,
assist,
dither,
&keybindings,
cli.profile,
cli.single_threaded,
cli.full_frames,
);
let _ = terminal::disable_raw_mode();
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::tcflush(io::stdout().as_raw_fd(), libc::TCIOFLUSH);
}
}
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = io::stdout().as_raw_fd();
let restore = b"\x1b[?2026l\x1b[?25h\x1b[?1049l";
unsafe {
libc::write(fd, restore.as_ptr() as *const libc::c_void, restore.len());
}
if cli.screensaver {
let mut stdout = io::stdout();
let _ = execute!(stdout, DisableFocusChange);
}
}
#[cfg(not(unix))]
{
let mut stdout = io::stdout();
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
if cli.screensaver {
let _ = execute!(stdout, DisableFocusChange);
}
}
if std::env::var("TMUX").is_ok() {
let _ = std::process::Command::new("tmux")
.args(["clear-history"])
.status();
let _ = std::process::Command::new("tmux")
.args(["refresh-client"])
.status();
}
if result.is_ok() {
std::process::exit(0);
}
result
}
const RENDER_MODES: [RenderMode; 3] = [
RenderMode::Braille,
RenderMode::HalfBlock,
RenderMode::Ascii,
];
const COLOR_MODES: [ColorMode; 4] = [
ColorMode::TrueColor,
ColorMode::Ansi256,
ColorMode::Ansi16,
ColorMode::Mono,
];
const TRANSITION_FRAMES: u8 = 8;
struct FrameProfile {
update_us: Vec<f64>,
render_us: Vec<f64>,
write_us: Vec<f64>,
total_us: Vec<f64>,
anim_name: String,
}
impl FrameProfile {
fn new(anim_name: &str) -> Self {
Self {
update_us: Vec::new(),
render_us: Vec::new(),
write_us: Vec::new(),
total_us: Vec::new(),
anim_name: anim_name.to_string(),
}
}
fn record(
&mut self,
update_dur: Duration,
render_dur: Duration,
write_dur: Duration,
total_dur: Duration,
) {
self.update_us.push(update_dur.as_secs_f64() * 1e6);
self.render_us.push(render_dur.as_secs_f64() * 1e6);
self.write_us.push(write_dur.as_secs_f64() * 1e6);
self.total_us.push(total_dur.as_secs_f64() * 1e6);
}
fn print_summary(&self) {
if self.total_us.is_empty() {
return;
}
let n = self.total_us.len();
let stats = |data: &[f64]| -> (f64, f64, f64, f64, f64) {
let mut sorted = data.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let sum: f64 = sorted.iter().sum();
let p95_idx = ((n as f64) * 0.95).ceil() as usize - 1;
(
sum / n as f64,
sorted[0],
sorted[n - 1],
sorted[p95_idx.min(n - 1)],
sum / 1e6, )
};
println!("\n=== Profile: {} ({} frames) ===", self.anim_name, n);
println!(
"{:<12} {:>10} {:>10} {:>10} {:>10}",
"", "avg µs", "min µs", "max µs", "p95 µs"
);
let (avg, min, max, p95, _) = stats(&self.update_us);
println!(
"{:<12} {:>10.1} {:>10.1} {:>10.1} {:>10.1}",
"update", avg, min, max, p95
);
let (avg, min, max, p95, _) = stats(&self.render_us);
println!(
"{:<12} {:>10.1} {:>10.1} {:>10.1} {:>10.1}",
"render", avg, min, max, p95
);
let (avg, min, max, p95, _) = stats(&self.write_us);
println!(
"{:<12} {:>10.1} {:>10.1} {:>10.1} {:>10.1}",
"write", avg, min, max, p95
);
let (avg, min, max, p95, total_secs) = stats(&self.total_us);
println!(
"{:<12} {:>10.1} {:>10.1} {:>10.1} {:>10.1}",
"total", avg, min, max, p95
);
if total_secs > 0.0 {
println!(
"Avg FPS: {:.1} | Total time: {:.2}s",
n as f64 / total_secs,
total_secs
);
}
println!();
}
}
enum TransitionState {
None,
FadingOut {
next_anim_index: usize,
remaining: u8,
},
FadingIn {
remaining: u8,
},
}
fn start_transition(transition: &mut TransitionState, next_anim_index: usize) {
*transition = TransitionState::FadingOut {
next_anim_index,
remaining: TRANSITION_FRAMES,
};
}
#[allow(clippy::too_many_arguments)]
fn run_loop(
initial_anim: &str,
explicit_render: Option<RenderMode>,
mut color_mode: ColorMode,
color_quant: u8,
unlimited: bool,
frame_dur: Duration,
mut scale: f64,
cycle: u32,
clean: bool,
screensaver: bool,
screensaver_keys: bool,
record_path: Option<&str>,
data_file: Option<String>,
mut postproc: PostProcessConfig,
mut smoothing_tau: f64,
default_smoothing_tau: f64,
default_bloom: f64,
assist: ColorAssist,
dither: bool,
keybindings: &KeyBindings,
profile: bool,
single_threaded: bool,
full_frames: bool,
) -> io::Result<()> {
let (mut cols, mut rows) = terminal::size()?;
let is_tmux = std::env::var("TMUX").is_ok();
let mut hide_status = clean;
let mut adaptive_frame_dur = frame_dur;
let mut write_time_ema: f64 = 0.0;
let display_rows = if hide_status {
rows as usize
} else {
(rows as usize).saturating_sub(1)
};
let temp_canvas = Canvas::new(
cols as usize,
display_rows,
RenderMode::HalfBlock,
color_mode,
);
let mut anim: Box<dyn Animation> =
animations::create(initial_anim, temp_canvas.width, temp_canvas.height, scale)
.expect("animation name validated before calling create");
let mut render_mode = explicit_render.unwrap_or_else(|| anim.preferred_render());
let mut canvas = Canvas::new(cols as usize, display_rows, render_mode, color_mode);
canvas.color_quant = color_quant;
canvas.dither = dither;
anim = animations::create(initial_anim, canvas.width, canvas.height, scale)
.expect("animation name validated before calling create");
anim.on_resize(canvas.width, canvas.height);
let mut anim_index = animations::ANIMATION_NAMES
.iter()
.position(|&n| n == initial_anim)
.unwrap_or(0);
let mut last_frame = Instant::now();
let mut cycle_start = Instant::now();
let mut frame_count: u64 = 0;
let mut actual_fps: f64 = 0.0;
let mut fps_update = Instant::now();
let mut recorder = record_path.map(|_| record::Recorder::new());
let mut needs_rebuild = false;
let mut resize_cooldown = Instant::now();
let params_rx: Option<mpsc::Receiver<ExternalParams>> = {
if let Some(path) = data_file {
Some(spawn_reader(ParamsSource::File(path.into())))
} else if !std::io::stdin().is_terminal() {
Some(spawn_reader(ParamsSource::Stdin))
} else {
None
}
};
let mut ext_state = CurrentState::default();
let mut transition = TransitionState::None;
let mut virtual_time: f64 = 0.0;
let mut frame_profile = profile.then(|| FrameProfile::new(initial_anim));
let quit = Arc::new(AtomicBool::new(false));
#[cfg(unix)]
let mut renderer: Option<render_sink::ThreadedRenderer> = if !single_threaded {
use std::os::unix::io::AsRawFd;
Some(render_sink::ThreadedRenderer::new(
quit.clone(),
io::stdout().as_raw_fd(),
))
} else {
None
};
#[cfg(not(unix))]
let _ = single_threaded;
use render::cell::CellGrid;
let mut prev_grid: Option<CellGrid> = None;
let result: io::Result<()> = 'outer: loop {
let time_to_next = adaptive_frame_dur.saturating_sub(last_frame.elapsed());
if event::poll(time_to_next)? {
loop {
match event::read()? {
Event::Resize(w, h) => {
cols = w;
rows = h;
needs_rebuild = true;
resize_cooldown = Instant::now();
}
Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
modifiers,
..
}) => {
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
quit.store(true, Ordering::Release);
break 'outer Ok(());
}
if screensaver && !screensaver_keys {
quit.store(true, Ordering::Release);
break 'outer Ok(());
}
match code {
kc if keybindings.quit.contains(&kc) => {
if let (Some(rec), Some(path)) = (recorder.take(), record_path) {
let mut stdout = io::stdout();
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
rec.save(path)?;
println!("Saved {} frames to {}", rec.frame_count(), path);
terminal::enable_raw_mode()?;
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)?;
}
quit.store(true, Ordering::Release);
break 'outer Ok(());
}
kc if keybindings.next.contains(&kc) => {
anim_index = (anim_index + 1) % animations::ANIMATION_NAMES.len();
start_transition(&mut transition, anim_index);
cycle_start = Instant::now();
}
kc if keybindings.prev.contains(&kc) => {
anim_index = if anim_index == 0 {
animations::ANIMATION_NAMES.len() - 1
} else {
anim_index - 1
};
start_transition(&mut transition, anim_index);
cycle_start = Instant::now();
}
kc if keybindings.render.contains(&kc) => {
let idx = RENDER_MODES
.iter()
.position(|&m| m == render_mode)
.unwrap_or(0);
render_mode = RENDER_MODES[(idx + 1) % RENDER_MODES.len()];
needs_rebuild = true;
}
kc if keybindings.color.contains(&kc) => {
let idx = COLOR_MODES
.iter()
.position(|&m| m == color_mode)
.unwrap_or(0);
color_mode = COLOR_MODES[(idx + 1) % COLOR_MODES.len()];
needs_rebuild = true;
}
kc if keybindings.status.contains(&kc) => {
hide_status = !hide_status;
needs_rebuild = true;
}
KeyCode::Char('b') => {
postproc.bloom = if postproc.bloom > 0.0 {
0.0
} else {
default_bloom
};
}
KeyCode::Char('s') => {
smoothing_tau = if smoothing_tau > 0.0 {
0.0
} else {
default_smoothing_tau
};
}
KeyCode::Char('d') => {
canvas.dither = !canvas.dither;
}
_ => {
if screensaver {
quit.store(true, Ordering::Release);
break 'outer Ok(());
}
}
}
}
Event::FocusGained if screensaver && !screensaver_keys => {
quit.store(true, Ordering::Release);
break 'outer Ok(());
}
_ => {}
}
if !event::poll(Duration::ZERO)? {
break;
}
}
}
if resize_cooldown.elapsed() < Duration::from_millis(100) {
needs_rebuild = true;
continue;
}
if needs_rebuild {
let (cur_cols, cur_rows) = terminal::size()?;
if cur_cols >= 10 && cur_rows >= 5 {
cols = cur_cols;
rows = cur_rows;
let display_rows = if hide_status {
rows as usize
} else {
(rows as usize).saturating_sub(1)
};
canvas = Canvas::new(cols as usize, display_rows, render_mode, color_mode);
canvas.color_quant = color_quant;
canvas.dither = dither;
anim = animations::create(
animations::ANIMATION_NAMES[anim_index],
canvas.width,
canvas.height,
scale,
)
.expect("animation name validated before calling create");
anim.on_resize(canvas.width, canvas.height);
}
prev_grid = None;
needs_rebuild = false;
last_frame = Instant::now();
continue; }
if cycle > 0 && cycle_start.elapsed() >= Duration::from_secs(cycle as u64) {
anim_index = (anim_index + 1) % animations::ANIMATION_NAMES.len();
start_transition(&mut transition, anim_index);
cycle_start = Instant::now();
}
let now = Instant::now();
let dt = now.duration_since(last_frame).as_secs_f64().min(0.1); last_frame = now;
if let Some(rx) = ¶ms_rx {
while let Ok(p) = rx.try_recv() {
ext_state.merge(p);
}
}
if let Some(name) = ext_state.take_animation_change()
&& animations::ANIMATION_NAMES.contains(&name.as_str())
{
anim_index = animations::ANIMATION_NAMES
.iter()
.position(|&n| n == name.as_str())
.unwrap_or(anim_index);
start_transition(&mut transition, anim_index);
cycle_start = Instant::now();
}
if let Some(new_scale) = ext_state.take_scale_change() {
scale = new_scale.clamp(0.5, 2.0);
anim = animations::create(
animations::ANIMATION_NAMES[anim_index],
canvas.width,
canvas.height,
scale,
)
.expect("animation name validated before calling create");
anim.on_resize(canvas.width, canvas.height);
prev_grid = None;
}
if let Some(render_name) = ext_state.take_render_change()
&& let Some(new_mode) = parse_render_mode(&render_name)
{
render_mode = new_mode;
needs_rebuild = true;
}
if let Some(color_name) = ext_state.take_color_change()
&& let Some(new_mode) = parse_color_mode(&color_name)
{
color_mode = new_mode;
needs_rebuild = true;
}
if needs_rebuild {
continue;
}
let speed = ext_state.speed().clamp(0.1, 5.0);
let effective_dt = (dt * speed).min(0.5);
virtual_time += effective_dt;
anim.set_params(ext_state.params());
let update_start = Instant::now();
anim.update(&mut canvas, effective_dt, virtual_time);
let update_dur = update_start.elapsed();
if smoothing_tau > 0.0 {
canvas.apply_smoothing(smoothing_alpha(effective_dt, smoothing_tau));
}
let transition_factor = match &mut transition {
TransitionState::None => 1.0,
TransitionState::FadingOut {
next_anim_index,
remaining,
} => {
let factor = *remaining as f64 / TRANSITION_FRAMES as f64;
if *remaining == 0 {
anim = animations::create(
animations::ANIMATION_NAMES[*next_anim_index],
canvas.width,
canvas.height,
scale,
)
.expect("animation name validated before calling create");
anim.on_resize(canvas.width, canvas.height);
if explicit_render.is_none() {
render_mode = anim.preferred_render();
needs_rebuild = true;
}
prev_grid = None;
transition = TransitionState::FadingIn {
remaining: TRANSITION_FRAMES,
};
0.0
} else {
*remaining -= 1;
factor
}
}
TransitionState::FadingIn { remaining } => {
let factor = 1.0 - *remaining as f64 / TRANSITION_FRAMES as f64;
if *remaining == 0 {
transition = TransitionState::None;
1.0
} else {
*remaining -= 1;
factor
}
}
};
if needs_rebuild {
continue;
}
let intensity = ext_state.intensity().clamp(0.0, 2.0) * transition_factor;
let hue = ext_state.color_shift().clamp(0.0, 1.0);
canvas.apply_effects(intensity, hue);
canvas.apply_color_assist(&assist);
canvas.post_process(&postproc);
let render_start = Instant::now();
let always_reset_row_end = !matches!(render_mode, RenderMode::HalfBlock);
let grid = canvas.render_cells();
let frame = match &prev_grid {
Some(p)
if p.cols == grid.cols
&& p.rows == grid.rows
&& !full_frames
&& recorder.is_none()
&& render::encoder::dirty_ratio(p, &grid)
<= render::encoder::FULL_REDRAW_THRESHOLD =>
{
render::encoder::encode_diff(p, &grid)
}
_ => render::encoder::encode_full(&grid, always_reset_row_end),
};
prev_grid = Some(grid);
let render_dur = render_start.elapsed();
if let Some(ref mut rec) = recorder {
rec.capture(&frame);
}
let mut frame_buf: Vec<u8> = Vec::with_capacity(256 * 1024);
frame_buf.extend_from_slice(b"\x1b[?2026h");
frame_buf.extend_from_slice(b"\x1b[H");
frame_buf.extend_from_slice(frame.as_bytes());
frame_count += 1;
if fps_update.elapsed() >= Duration::from_secs(1) {
actual_fps = frame_count as f64 / fps_update.elapsed().as_secs_f64();
frame_count = 0;
fps_update = Instant::now();
}
if !hide_status {
let rec_indicator = if recorder.is_some() { " [REC]" } else { "" };
let fps_str = if unlimited {
"∞ fps".to_string()
} else {
format!("{:.0} fps", actual_fps)
};
let bloom_str = if postproc.bloom > 0.0 { "ON" } else { "off" };
let smooth_str = if smoothing_tau > 0.0 { "ON" } else { "off" };
let dither_str = if canvas.dither { "ON" } else { "off" };
let assist_str = match &assist {
ColorAssist::None => String::new(),
ColorAssist::Remap(p) => format!(" | pal:{}", p.name()),
ColorAssist::Daltonize(d) => format!(" | cb:{}", d.name()),
};
let status = format!(
" {} | {:?} | {:?} | {}{} | bloom:{} | smooth:{} | dither:{}{assist_str} | [←/→] anim [b] bloom [s] smooth [d] dither [r] render [c] color [h] hide [q] quit ",
anim.name(),
render_mode,
color_mode,
fps_str,
rec_indicator,
bloom_str,
smooth_str,
dither_str,
);
let w = cols as usize;
let truncated: String = status.chars().take(w).collect();
let padded = format!("{:<width$}", truncated, width = w);
frame_buf
.extend_from_slice(format!("\x1b[{};1H\x1b[7m{}\x1b[0m", rows, padded).as_bytes());
}
let (final_cols, final_rows) = terminal::size()?;
if final_cols != cols || final_rows != rows {
cols = final_cols;
rows = final_rows;
needs_rebuild = true;
resize_cooldown = Instant::now();
continue; }
frame_buf.extend_from_slice(b"\x1b[?2026l");
let write_start = Instant::now();
#[cfg(unix)]
{
let outcome = if let Some(ref mut r) = renderer {
match r.submit(frame_buf, &quit, &keybindings.quit) {
Ok(render_sink::SubmitResult::Ok) => render_sink::WriteOutcome::Complete,
Ok(render_sink::SubmitResult::Quit) => render_sink::WriteOutcome::QuitSignaled,
Ok(render_sink::SubmitResult::WriterDied) => break 'outer Ok(()),
Err(e) => break 'outer Err(e),
}
} else {
use std::os::unix::io::AsRawFd;
let fd = io::stdout().as_raw_fd();
match render_sink::write_chunked(fd, &frame_buf, || {
if event::poll(Duration::ZERO)?
&& let Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
modifiers,
..
}) = event::read()?
&& render_sink::is_quit_key(code, modifiers, &keybindings.quit)
{
quit.store(true, Ordering::Release);
return Ok(true);
}
Ok(false)
}) {
Ok(o) => o,
Err(e) => break 'outer Err(e),
}
};
if matches!(outcome, render_sink::WriteOutcome::QuitSignaled) {
break 'outer Ok(());
}
}
#[cfg(not(unix))]
{
use std::io::Write;
let mut stdout = io::stdout().lock();
stdout.write_all(&frame_buf)?;
stdout.flush()?;
}
if let Some(ref mut p) = frame_profile {
#[cfg(unix)]
let write_dur = if !single_threaded {
Duration::from_secs_f64(renderer.as_ref().map_or(0.0, |r| r.write_time_secs()))
} else {
write_start.elapsed()
};
#[cfg(not(unix))]
let write_dur = write_start.elapsed();
let total_dur = update_dur + render_dur + write_dur;
p.record(update_dur, render_dur, write_dur, total_dur);
}
if is_tmux || unlimited {
#[cfg(unix)]
let write_secs = match renderer.as_ref() {
Some(r) => r.write_time_secs(),
None => write_start.elapsed().as_secs_f64(),
};
#[cfg(not(unix))]
let write_secs = write_start.elapsed().as_secs_f64();
write_time_ema = write_time_ema * 0.8 + write_secs * 0.2;
let target =
Duration::from_secs_f64((write_time_ema * 1.1).max(frame_dur.as_secs_f64()));
adaptive_frame_dur = target.min(Duration::from_millis(200)); }
};
#[cfg(unix)]
if let Some(r) = renderer {
let _ = r.shutdown();
}
if let Some(ref p) = frame_profile {
p.print_summary();
}
result
}
fn detect_recording_size(frames: &[record::Frame]) -> (usize, usize) {
let mut max_row = 24usize;
let mut max_col = 80usize;
if let Some(frame) = frames.first() {
let bytes = frame.content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b';') {
i += 1;
}
if i < bytes.len() && bytes[i] == b'H' {
let params = &frame.content.as_bytes()[start..i];
let s = std::str::from_utf8(params).unwrap_or("1;1");
let parts: Vec<&str> = s.split(';').collect();
if parts.len() >= 2 {
if let Ok(r) = parts[0].parse::<usize>() {
max_row = max_row.max(r);
}
if let Ok(c) = parts[1].parse::<usize>() {
max_col = max_col.max(c);
}
}
}
}
i += 1;
}
}
(max_col, max_row)
}
fn parse_render_mode(s: &str) -> Option<RenderMode> {
match s {
"braille" => Some(RenderMode::Braille),
"half-block" | "halfblock" => Some(RenderMode::HalfBlock),
"ascii" => Some(RenderMode::Ascii),
_ => None,
}
}
fn parse_color_mode(s: &str) -> Option<ColorMode> {
match s {
"mono" => Some(ColorMode::Mono),
"ansi16" => Some(ColorMode::Ansi16),
"ansi256" => Some(ColorMode::Ansi256),
"true-color" | "truecolor" => Some(ColorMode::TrueColor),
_ => None,
}
}
fn parse_key_binding(s: &str) -> Option<(KeyCode, KeyModifiers)> {
let s = s.trim();
if let Some((mods, key)) = s.split_once('+') {
let key_code = parse_key_code(key.trim())?;
let modifiers = match mods.trim().to_ascii_lowercase().as_str() {
"ctrl" => KeyModifiers::CONTROL,
"alt" => KeyModifiers::ALT,
"shift" => KeyModifiers::SHIFT,
_ => return None,
};
return Some((key_code, modifiers));
}
let key_code = parse_key_code(s)?;
Some((key_code, KeyModifiers::NONE))
}
fn parse_key_code(s: &str) -> Option<KeyCode> {
match s {
"Left" => Some(KeyCode::Left),
"Right" => Some(KeyCode::Right),
"Up" => Some(KeyCode::Up),
"Down" => Some(KeyCode::Down),
"Esc" => Some(KeyCode::Esc),
"Enter" => Some(KeyCode::Enter),
"Space" => Some(KeyCode::Char(' ')),
"Tab" => Some(KeyCode::Tab),
s if s.len() == 1 => Some(KeyCode::Char(s.chars().next().unwrap())),
_ => None,
}
}
struct KeyBindings {
next: Vec<KeyCode>,
prev: Vec<KeyCode>,
quit: Vec<KeyCode>,
render: Vec<KeyCode>,
color: Vec<KeyCode>,
status: Vec<KeyCode>,
}
impl KeyBindings {
fn defaults() -> Self {
KeyBindings {
next: vec![KeyCode::Right, KeyCode::Char('n')],
prev: vec![KeyCode::Left, KeyCode::Char('p')],
quit: vec![KeyCode::Char('q'), KeyCode::Esc],
render: vec![KeyCode::Char('r')],
color: vec![KeyCode::Char('c')],
status: vec![KeyCode::Char('h')],
}
}
}
fn build_keybindings(cfg: &config::Config) -> KeyBindings {
let kb = cfg.keybindings.as_ref();
let defaults = KeyBindings::defaults();
KeyBindings {
next: kb
.and_then(|m| m.get("next"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.next),
prev: kb
.and_then(|m| m.get("prev"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.prev),
quit: kb
.and_then(|m| m.get("quit"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.quit),
render: kb
.and_then(|m| m.get("render"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.render),
color: kb
.and_then(|m| m.get("color"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.color),
status: kb
.and_then(|m| m.get("status"))
.and_then(|s| parse_key_binding(s))
.map(|(c, _)| vec![c])
.unwrap_or(defaults.status),
}
}