use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use super::blit::Blitter;
use image::imageops::FilterType;
use image::{DynamicImage, RgbaImage};
use ratatui::layout::Rect;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::Protocol;
use ratatui_image::Resize;
use super::decode;
use crate::http;
pub const EMOTE_H: u16 = 2;
const MAX_W: u16 = 8;
pub(crate) const MAX_FRAMES: usize = 120;
const MAX_LIVE_FRAMES: usize = 6000;
const DRAW_BUDGET: usize = 96;
pub(crate) const MIN_DELAY_MS: u32 = 20;
const PALETTE_COLORS: u32 = 200;
struct FrameProto {
proto: Protocol,
delay_ms: u32,
}
struct Anim {
frames: Vec<FrameProto>,
w_cells: u16,
total_ms: u64,
shown: Cell<usize>,
used: Cell<u64>,
}
enum Entry {
Loading,
Ready(Anim),
Failed,
}
struct Job {
key: String, }
struct Done {
key: String,
built: Option<Built>,
}
struct Built {
frames: Vec<FrameProto>,
w_cells: u16,
total_ms: u64,
}
pub struct EmoteStore {
proto: ProtocolType, tmux: bool,
native: bool,
outer_ok: Arc<AtomicBool>,
pane_off: Arc<AtomicU32>,
watcher: Option<thread::Thread>,
last_area: Cell<Rect>,
square_w: u16,
cache: HashMap<String, Entry>,
jobs: Sender<Job>,
done: Receiver<Done>,
start: Instant,
now_ms: Cell<u64>,
next_flip_ms: Cell<u64>,
budget_left: Cell<usize>,
lru: Cell<u64>,
frame_mark: Cell<u64>,
live_frames: usize,
blit: RefCell<Blitter>,
}
impl EmoteStore {
pub fn new() -> Option<EmoteStore> {
let picker = if std::env::var_os("TMUX").is_some() {
let (proto, _) = tmux_outer_protocol()?;
let mut p = Picker::from_fontsize(cell_pixels()?);
p.set_protocol_type(proto);
p
} else {
if !graphics_capable_env() {
return None;
}
Picker::from_query_stdio().ok()?
};
let proto = picker.protocol_type();
if proto == ProtocolType::Halfblocks {
return None;
}
let (cw, ch) = picker.font_size();
let square_w = width_cells(1.0, cw, ch);
let (jobs_tx, jobs_rx) = mpsc::channel::<Job>();
let (done_tx, done_rx) = mpsc::channel::<Done>();
let jobs_rx = Arc::new(Mutex::new(jobs_rx));
let workers = thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2)
.clamp(2, 4);
let tmux = std::env::var_os("TMUX").is_some();
let native = tmux
&& proto == ProtocolType::Sixel
&& tmux_outer_protocol_now().is_some_and(|o| o.is_some_and(|(_, n)| n));
let wrap = match (tmux && proto == ProtocolType::Sixel, native) {
(true, true) => SixelWrap::Native,
(true, false) => SixelWrap::Passthrough,
_ => SixelWrap::Bare,
};
for _ in 0..workers {
let rx = Arc::clone(&jobs_rx);
let tx = done_tx.clone();
let p = picker.clone();
thread::spawn(move || loader(p, rx, tx, wrap));
}
let outer_ok = Arc::new(AtomicBool::new(true));
let pane_off = Arc::new(AtomicU32::new(NO_OFFSET));
let mut watcher = None;
if tmux {
let flag = Arc::clone(&outer_ok);
let off = Arc::clone(&pane_off);
let h = thread::spawn(move || loop {
if let Some(o) = tmux_outer_protocol_now() {
let same = o.is_some_and(|(p, n)| {
p == proto && (n == native || proto != ProtocolType::Sixel)
});
flag.store(same, Ordering::Relaxed);
}
off.store(
pane_offset().map_or(NO_OFFSET, |(x, y)| u32::from(x) << 16 | u32::from(y)),
Ordering::Relaxed,
);
thread::park_timeout(Duration::from_secs(2));
});
watcher = Some(h.thread().clone());
}
Some(EmoteStore {
proto,
tmux,
native,
outer_ok,
pane_off,
watcher,
last_area: Cell::new(Rect::default()),
square_w,
cache: HashMap::new(),
jobs: jobs_tx,
done: done_rx,
start: Instant::now(),
now_ms: Cell::new(0),
next_flip_ms: Cell::new(u64::MAX),
budget_left: Cell::new(DRAW_BUDGET),
lru: Cell::new(0),
frame_mark: Cell::new(0),
live_frames: 0,
blit: RefCell::new(Blitter::default()),
})
}
pub fn square_cells(&self) -> u16 {
self.square_w
}
pub fn usable(&self) -> bool {
!self.tmux || self.outer_ok.load(Ordering::Relaxed)
}
pub fn sync_ok(&self) -> bool {
!self.tmux || self.native
}
pub fn tier_label(&self) -> &'static str {
match self.proto {
ProtocolType::Sixel => "sixel",
ProtocolType::Kitty => "kitty",
ProtocolType::Iterm2 => "iterm2",
ProtocolType::Halfblocks => "halfblocks",
}
}
pub fn request(&mut self, key: &str) {
if self.cache.contains_key(key) {
return;
}
self.cache.insert(key.to_string(), Entry::Loading);
if self
.jobs
.send(Job {
key: key.to_string(),
})
.is_err()
{
self.cache.insert(key.to_string(), Entry::Failed);
}
}
pub fn pump(&mut self) -> bool {
let mut loaded = false;
let stamp = self.lru.get();
while let Ok(d) = self.done.try_recv() {
let entry = match d.built {
Some(b) if !b.frames.is_empty() => {
self.live_frames += b.frames.len();
Entry::Ready(Anim {
frames: b.frames,
w_cells: b.w_cells,
total_ms: b.total_ms,
shown: Cell::new(0),
used: Cell::new(stamp),
})
}
_ => Entry::Failed,
};
if let Some(Entry::Ready(old)) = self.cache.insert(d.key, entry) {
self.live_frames -= old.frames.len(); }
loaded = true;
}
if loaded {
self.evict();
}
loaded
}
fn evict(&mut self) {
if self.live_frames <= MAX_LIVE_FRAMES {
return;
}
let mark = self.frame_mark.get();
let mut aged: Vec<(u64, String)> = self
.cache
.iter()
.filter_map(|(k, e)| match e {
Entry::Ready(a) if a.used.get() < mark => Some((a.used.get(), k.clone())),
_ => None,
})
.collect();
aged.sort_unstable_by_key(|(used, _)| *used);
for (_, key) in aged {
if self.live_frames <= MAX_LIVE_FRAMES {
break;
}
if let Some(Entry::Ready(a)) = self.cache.remove(&key) {
self.live_frames -= a.frames.len();
}
}
}
pub fn begin_frame(&self) {
self.now_ms.set(self.start.elapsed().as_millis() as u64);
self.budget_left.set(DRAW_BUDGET);
self.next_flip_ms.set(u64::MAX);
self.frame_mark.set(self.lru.get());
}
pub fn next_flip_in(&self) -> Option<Duration> {
let soonest = self.next_flip_ms.get();
(soonest != u64::MAX).then(|| Duration::from_millis(soonest.max(1)))
}
pub fn cells(&self, key: &str) -> Option<u16> {
match self.cache.get(key) {
Some(Entry::Ready(a)) => {
self.touch(a);
Some(a.w_cells)
}
_ => None,
}
}
pub fn frame(&self, key: &str) -> Option<&Protocol> {
let Some(Entry::Ready(a)) = self.cache.get(key) else {
return None;
};
self.touch(a);
let idx = if a.frames.len() == 1 {
0
} else {
let left = self.budget_left.get();
if left == 0 {
self.next_flip_ms
.set(self.next_flip_ms.get().min(MIN_DELAY_MS as u64));
a.shown.get().min(a.frames.len() - 1)
} else {
self.budget_left.set(left - 1);
let (idx, flip_in) = frame_at(&a.frames, self.now_ms.get(), a.total_ms);
self.next_flip_ms.set(self.next_flip_ms.get().min(flip_in));
idx
}
};
a.shown.set(idx);
Some(&a.frames[idx].proto)
}
pub fn is_sixel(&self, key: &str) -> bool {
matches!(self.cache.get(key), Some(Entry::Ready(a))
if matches!(a.frames[a.shown.get().min(a.frames.len() - 1)].proto, Protocol::Sixel(_)))
}
pub fn scroll_ok(&self) -> bool {
self.proto == ProtocolType::Sixel && self.sync_ok()
}
pub fn shift_blits(&self, top: u16, h: u16, k: u16) -> bool {
self.blit.borrow_mut().shift_up(top, h, k)
}
pub fn queue_blit(&self, rect: Rect, key: &str) {
if let Some(Entry::Ready(a)) = self.cache.get(key) {
self.blit.borrow_mut().push(rect, key, a.shown.get());
}
}
pub fn blit(&self, area: Rect, out: &mut impl std::io::Write) -> std::io::Result<()> {
if self.last_area.replace(area) != area {
self.pane_off.store(NO_OFFSET, Ordering::Relaxed);
if let Some(w) = &self.watcher {
w.unpark();
}
}
let emits = self.blit.borrow_mut().take_emits(area);
let off = self.pane_off.load(Ordering::Relaxed);
let off = (off != NO_OFFSET).then_some(((off >> 16) as u16, off as u16));
if emits.is_empty() {
return Ok(());
}
for q in emits {
let Some(Entry::Ready(a)) = self.cache.get(&q.key) else {
continue;
};
let Some(f) = a.frames.get(q.idx) else {
continue;
};
if let Protocol::Sixel(s) = &f.proto {
let wrapped = off
.filter(|_| q.in_place && self.tmux && self.native)
.map(|o| passthrough_in_place(&s.data, o, (q.rect.x, q.rect.y)));
if let Some(w) = &wrapped {
crossterm::queue!(out, crossterm::style::Print(w))?;
} else {
crossterm::queue!(
out,
crossterm::cursor::MoveTo(q.rect.x, q.rect.y),
crossterm::style::Print(&s.data)
)?;
}
}
}
out.flush()
}
fn touch(&self, a: &Anim) {
let t = self.lru.get() + 1;
self.lru.set(t);
a.used.set(t);
}
}
fn cell_pixels() -> Option<(u16, u16)> {
let ws = crossterm::terminal::window_size().ok()?;
if ws.width == 0 || ws.height == 0 || ws.columns == 0 || ws.rows == 0 {
return None;
}
Some((ws.width / ws.columns, ws.height / ws.rows))
}
fn tmux_outer_protocol() -> Option<(ProtocolType, bool)> {
tmux_outer_protocol_now().flatten()
}
fn tmux_outer_protocol_now() -> Option<Option<(ProtocolType, bool)>> {
let out = std::process::Command::new("tmux")
.args([
"display-message",
"-p",
"#{client_termname}|#{client_termfeatures}",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
let (term, feats) = text.split_once('|').unwrap_or((&text, ""));
let native = feats.split(',').any(|f| f == "sixel");
Some(
if term.contains("kitty") || term.contains("ghostty") || term.contains("wezterm") {
Some((ProtocolType::Kitty, false))
} else if term.starts_with("foot") || term.contains("mlterm") || term.contains("contour") {
Some((ProtocolType::Sixel, native))
} else {
None
},
)
}
#[derive(Clone, Copy)]
enum SixelWrap {
Bare,
Passthrough,
Native,
}
fn unwrap_tmux_passthrough(proto: &mut Protocol) {
let Protocol::Sixel(s) = proto else {
return;
};
let Some(sixel) = s
.data
.strip_prefix("\x1bPtmux;\x1b\x1b")
.and_then(|d| d.strip_suffix("\x1b\\"))
.filter(|d| d.starts_with("\x1bP") && d.ends_with("\x1b\\"))
else {
return;
};
s.data = format!("{}{sixel}", erase_block(s.area.width, s.area.height));
s.is_tmux = false;
}
fn fix_tmux_passthrough(proto: &mut Protocol) {
let Protocol::Sixel(s) = proto else {
return;
};
if !s.is_tmux {
return;
}
let Some(sixel) = s
.data
.strip_prefix("\x1bPtmux;\x1b\x1b")
.and_then(|d| d.strip_suffix("\x1b\\"))
else {
return;
};
s.data = format!(
"{}\x1bPtmux;{}\x1b\\",
erase_block(s.area.width, s.area.height),
sixel.replace('\x1b', "\x1b\x1b")
);
}
const NO_OFFSET: u32 = u32::MAX;
const PANE_FMT: &str = "#{&&:#{window_active},#{pane_active}} #{pane_left} #{pane_top} \
#{?#{==:#{status-position},top},#{?#{==:#{status},off},0,#{?#{==:#{status},on},1,#{status}}},0}";
fn pane_offset() -> Option<(u16, u16)> {
let pane = std::env::var("TMUX_PANE").ok()?;
let out = std::process::Command::new("tmux")
.args(["display-message", "-p", "-t", &pane, PANE_FMT])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_pane_offset(&String::from_utf8_lossy(&out.stdout))
}
fn parse_pane_offset(s: &str) -> Option<(u16, u16)> {
let mut f = s.split_whitespace();
let visible = f.next()? == "1";
let left: u16 = f.next()?.parse().ok()?;
let top: u16 = f.next()?.parse().ok()?;
let status: u16 = f.next()?.parse().ok()?;
visible.then_some((left, top + status))
}
fn passthrough_in_place(native: &str, off: (u16, u16), at: (u16, u16)) -> String {
let sixel = native.find("\x1bP").map_or(native, |i| &native[i..]);
let payload = format!(
"\x1b7\x1b[{};{}H{sixel}\x1b8",
off.1 + at.1 + 1,
off.0 + at.0 + 1
);
format!("\x1bPtmux;{}\x1b\\", payload.replace('\x1b', "\x1b\x1b"))
}
fn erase_block(w: u16, h: u16) -> String {
if w == 0 || h == 0 {
return String::new();
}
let mut out = String::from("\x1b7"); for row in 0..h {
out.push_str(&format!("\x1b[{w}X")); if row + 1 < h {
out.push_str("\x1b[B"); }
}
out.push_str("\x1b8"); out
}
fn graphics_capable_env() -> bool {
let e = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let term = e("TERM").unwrap_or_default();
let prog = e("TERM_PROGRAM").unwrap_or_default();
if e("KITTY_WINDOW_ID").is_some() || term.contains("kitty") || term.contains("ghostty") {
return true;
}
if term.starts_with("foot") {
return true;
}
if prog == "WezTerm" || e("WEZTERM_EXECUTABLE").is_some() {
return true;
}
if prog == "iTerm.app" || e("ITERM_SESSION_ID").is_some() || term.contains("mintty") {
return true;
}
if term.contains("mlterm") || term.contains("contour") || term.contains("st-") {
return true;
}
if let Some(v) = e("VTE_VERSION") {
if v.parse::<u32>().map(|n| n >= 7800).unwrap_or(false) {
return true;
}
}
false
}
fn frame_at(frames: &[FrameProto], now_ms: u64, total_ms: u64) -> (usize, u64) {
if frames.len() <= 1 || total_ms == 0 {
return (0, u64::MAX);
}
let mut t = now_ms % total_ms;
for (i, f) in frames.iter().enumerate() {
let d = f.delay_ms.max(MIN_DELAY_MS) as u64;
if t < d {
return (i, d - t);
}
t -= d;
}
(0, 1)
}
pub(crate) fn width_cells(aspect: f32, cw: u16, ch: u16) -> u16 {
let block_h = (EMOTE_H as f32) * (ch.max(1) as f32);
let want_px = block_h * aspect.max(0.01);
((want_px / cw.max(1) as f32).round() as i64).clamp(1, MAX_W as i64) as u16
}
pub(crate) fn fit_center(src: &RgbaImage, tw: u32, th: u32) -> RgbaImage {
let (sw, sh) = src.dimensions();
let mut canvas = RgbaImage::new(tw.max(1), th.max(1));
if sw == 0 || sh == 0 {
return canvas;
}
let ratio = (tw as f32 / sw as f32).min(th as f32 / sh as f32);
let nw = ((sw as f32 * ratio).round() as u32).clamp(1, tw.max(1));
let nh = ((sh as f32 * ratio).round() as u32).clamp(1, th.max(1));
let filter = if ratio < 1.0 {
FilterType::Lanczos3
} else {
FilterType::Triangle
};
let scaled = if (nw, nh) == (sw, sh) {
src.clone()
} else {
image::imageops::resize(src, nw, nh, filter)
};
image::imageops::replace(
&mut canvas,
&scaled,
((tw - nw) / 2) as i64,
((th - nh) / 2) as i64,
);
canvas
}
pub(crate) fn flatten_onto_black(img: &mut RgbaImage) {
for px in img.pixels_mut() {
let a = px[3] as u16;
px[0] = (px[0] as u16 * a / 255) as u8;
px[1] = (px[1] as u16 * a / 255) as u8;
px[2] = (px[2] as u16 * a / 255) as u8;
px[3] = 255;
}
}
const TRAIN_PX: usize = 20_000;
fn train_factor(px: usize) -> i32 {
(px / TRAIN_PX).clamp(1, 10) as i32
}
pub(crate) fn stabilize_palette(frames: &mut [RgbaImage]) {
if frames.len() < 2 {
return;
}
let total: usize = frames.iter().map(|f| f.as_raw().len()).sum();
let mut sample: Vec<u8> = Vec::with_capacity(total);
for f in frames.iter() {
sample.extend_from_slice(f.as_raw());
}
if sample.is_empty() {
return;
}
let nq = color_quant::NeuQuant::new(
train_factor(sample.len() / 4),
PALETTE_COLORS as usize,
&sample,
);
let map = nq.color_map_rgba();
let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new();
for f in frames.iter_mut() {
for px in f.pixels_mut() {
let key = px.0;
let rgb = *memo.entry(key).or_insert_with(|| {
let i = nq.index_of(&key) * 4;
if i + 2 < map.len() {
[map[i], map[i + 1], map[i + 2]]
} else {
[key[0], key[1], key[2]]
}
});
px.0 = [rgb[0], rgb[1], rgb[2], key[3]];
}
}
}
fn alpha_over(base: &mut RgbaImage, over: &RgbaImage) {
for (b, o) in base.pixels_mut().zip(over.pixels()) {
let oa = o[3] as u16;
let ia = 255 - oa;
for c in 0..3 {
b[c] = ((o[c] as u16 * oa + b[c] as u16 * ia) / 255) as u8;
}
b[3] = (oa + b[3] as u16 * ia / 255) as u8;
}
}
fn sample_frame(frames: &[decode::Frame], t_ms: u64) -> usize {
if frames.len() <= 1 {
return 0;
}
let total: u64 = frames
.iter()
.map(|f| f.delay_ms.max(MIN_DELAY_MS) as u64)
.sum();
if total == 0 {
return 0;
}
let mut t = t_ms % total;
for (i, f) in frames.iter().enumerate() {
let d = f.delay_ms.max(MIN_DELAY_MS) as u64;
if t < d {
return i;
}
t -= d;
}
0
}
fn source_url(url: &str, min_px: u32) -> Option<String> {
if min_px <= 34 {
return None; }
let swap = |from: &str, to: &str| {
let s = url.replacen(from, to, 1);
(s != url).then_some(s)
};
if url.contains("cdn.7tv.app") {
let t = if min_px > 96 {
"/4x"
} else if min_px > 64 {
"/3x"
} else {
"/2x"
};
return swap("/1x", t);
}
if url.contains("betterttv.net") {
return swap("/1x", if min_px > 64 { "/3x" } else { "/2x" });
}
if url.contains("frankerfacez.com") {
let t = if min_px > 64 { "4" } else { "2" };
return url.strip_suffix("/1").map(|s| format!("{s}/{t}"));
}
if url.contains("jtvnw.net") {
let t = if min_px > 64 { "3.0" } else { "2.0" };
return url.strip_suffix("/1.0").map(|s| format!("{s}/{t}"));
}
None
}
fn fetch_at(url: &str, min_px: u32) -> Option<Vec<u8>> {
if let Some(alt) = source_url(url, min_px) {
if let Some(bytes) = http::image_bytes(&alt) {
return Some(bytes);
}
}
http::image_bytes(url)
}
pub(crate) fn subsample(frames: Vec<(RgbaImage, u32)>, max: usize) -> Vec<(RgbaImage, u32)> {
if frames.len() <= max || max == 0 {
return frames;
}
let total: u64 = frames.iter().map(|f| f.1.max(MIN_DELAY_MS) as u64).sum();
let per = ((total / max as u64) as u32).max(MIN_DELAY_MS);
let last = frames.len() - 1;
let step = frames.len() as f64 / max as f64;
let keep: Vec<usize> = (0..max)
.map(|i| ((i as f64 * step).round() as usize).min(last))
.collect();
frames
.into_iter()
.enumerate()
.filter(|(i, _)| keep.binary_search(i).is_ok())
.map(|(_, f)| (f.0, per))
.collect()
}
fn loader(picker: Picker, jobs: Arc<Mutex<Receiver<Job>>>, done: Sender<Done>, wrap: SixelWrap) {
let flatten = picker.protocol_type() == ProtocolType::Sixel;
loop {
let job = match jobs.lock() {
Ok(rx) => match rx.recv() {
Ok(j) => j,
Err(_) => return, },
Err(_) => return,
};
let built = build(&picker, &job.key, flatten, wrap);
if done
.send(Done {
key: job.key,
built,
})
.is_err()
{
return; }
}
}
pub fn composite_frames(key: &str, min_px: u32) -> Option<Vec<(RgbaImage, u32)>> {
let mut layers: Vec<decode::Decoded> = Vec::new();
for spec in heatsync_core::emote::split_key(key) {
let bytes = fetch_at(&spec.url, min_px)?;
let mut layer = decode::decode(&bytes).ok()?;
if !spec.fx.is_empty() || spec.hue.is_some() {
let frames = layer
.frames
.into_iter()
.map(|f| (f.img, f.delay_ms))
.collect();
layer.frames = super::fx::apply(frames, &spec.fx, spec.hue)
.into_iter()
.map(|(img, delay_ms)| decode::Frame { img, delay_ms })
.collect();
if layer.frames.is_empty() {
return None;
}
}
layers.push(layer);
}
if layers.is_empty() {
return None;
}
let (tw, th) = layers[0].frames.first()?.img.dimensions();
let single = layers.len() == 1;
let driver = layers
.iter()
.max_by_key(|l| l.frames.len())
.expect("≥1 layer");
let mut out = Vec::with_capacity(driver.frames.len());
let mut t = 0u64;
for df in &driver.frames {
let canvas = if single {
layers[0].frames[sample_frame(&layers[0].frames, t)]
.img
.clone()
} else {
let mut c = RgbaImage::new(tw, th);
for layer in &layers {
let f = &layer.frames[sample_frame(&layer.frames, t)];
if f.img.dimensions() == (tw, th) {
alpha_over(&mut c, &f.img);
} else {
let r = image::imageops::resize(&f.img, tw, th, FilterType::Lanczos3);
alpha_over(&mut c, &r);
}
}
c
};
out.push((canvas, df.delay_ms));
t += df.delay_ms.max(MIN_DELAY_MS) as u64;
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn build(picker: &Picker, key: &str, flatten: bool, wrap: SixelWrap) -> Option<Built> {
let (cw, ch) = picker.font_size();
let block_h = EMOTE_H as u32 * ch.max(1) as u32;
let frames = subsample(composite_frames(key, block_h)?, MAX_FRAMES);
let (sw, sh) = frames[0].0.dimensions();
if sw == 0 || sh == 0 {
return None;
}
let w_cells = width_cells(sw as f32 / sh as f32, cw, ch);
let (tw, th) = (w_cells as u32 * cw.max(1) as u32, block_h);
let mut canvases: Vec<RgbaImage> = frames.iter().map(|(f, _)| fit_center(f, tw, th)).collect();
if flatten {
for c in &mut canvases {
flatten_onto_black(c);
}
stabilize_palette(&mut canvases);
}
let size = Rect::new(0, 0, w_cells, EMOTE_H);
let mut out = Vec::with_capacity(canvases.len());
let mut total_ms = 0u64;
for (canvas, (_, delay_ms)) in canvases.into_iter().zip(frames.iter()) {
let mut proto = picker
.new_protocol(DynamicImage::ImageRgba8(canvas), size, Resize::Fit(None))
.ok()?;
match wrap {
SixelWrap::Passthrough => fix_tmux_passthrough(&mut proto),
SixelWrap::Native => unwrap_tmux_passthrough(&mut proto),
SixelWrap::Bare => {}
}
let delay_ms = (*delay_ms).max(MIN_DELAY_MS);
total_ms += delay_ms as u64;
out.push(FrameProto { proto, delay_ms });
}
if out.is_empty() {
None
} else {
Some(Built {
frames: out,
w_cells,
total_ms,
})
}
}
#[cfg(test)]
mod tests {
#[test]
fn in_place_frame_is_absolute_doubled_passthrough_without_the_erase() {
let native = format!("{}\x1bPq#0;2;0;0;0~\x1b\\", super::erase_block(2, 2));
let got = super::passthrough_in_place(&native, (29, 1), (3, 4));
assert_eq!(
got,
"\x1bPtmux;\x1b\x1b7\x1b\x1b[6;33H\x1b\x1bPq#0;2;0;0;0~\x1b\x1b\\\x1b\x1b8\x1b\\"
);
}
#[test]
fn pane_offset_needs_a_visible_pane() {
assert_eq!(super::parse_pane_offset("1 29 0 1\n"), Some((29, 1)));
assert_eq!(super::parse_pane_offset("0 29 0 1"), None);
assert_eq!(super::parse_pane_offset("garbage"), None);
}
use super::*;
use image::{Rgba, RgbaImage};
fn tmux_forwards(data: &str) -> String {
let at = data.find("\x1bPtmux;").expect("passthrough prefix");
let body = data[at + "\x1bPtmux;".len()..]
.strip_suffix("\x1b\\")
.expect("passthrough terminator");
let mut out = String::new();
let mut it = body.chars();
while let Some(c) = it.next() {
if c != '\x1b' {
out.push(c);
continue;
}
match it.next() {
Some('\x1b') => out.push('\x1b'),
_ => break,
}
}
out
}
#[test]
fn native_unwrap_yields_plain_sixel() {
use ratatui_image::protocol::sixel::Sixel;
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(8, 8, Rgba([255, 135, 0, 255])));
let area = Rect::new(0, 0, 2, 2);
let plain = Sixel::new(img.clone(), area, false).expect("encode");
let mut p = Protocol::Sixel(Sixel::new(img, area, true).expect("encode"));
unwrap_tmux_passthrough(&mut p);
let Protocol::Sixel(s) = &p else {
unreachable!()
};
assert!(!s.is_tmux);
assert!(!s.data.contains("tmux;"));
assert_eq!(s.data, format!("{}{}", erase_block(2, 2), plain.data));
}
#[test]
fn passthrough_forwards_a_complete_terminated_sixel() {
use ratatui_image::protocol::sixel::Sixel;
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(8, 8, Rgba([255, 135, 0, 255])));
let area = Rect::new(0, 0, 2, 2);
let plain = Sixel::new(img.clone(), area, false).expect("encode");
assert!(plain.data.ends_with("\x1b\\"), "sixel is ST-terminated");
let raw_wrap = Sixel::new(img.clone(), area, true).expect("encode");
assert!(!tmux_forwards(&raw_wrap.data).ends_with("\x1b\\"));
let mut fixed = Protocol::Sixel(Sixel::new(img, area, true).expect("encode"));
fix_tmux_passthrough(&mut fixed);
let Protocol::Sixel(s) = &fixed else {
unreachable!()
};
assert_eq!(tmux_forwards(&s.data), plain.data);
assert!(s.is_tmux, "still passthrough — tmux stays blind by design");
let head = &s.data[..s.data.find("\x1bPtmux;").unwrap()];
assert_eq!(head, "\x1b7\x1b[2X\x1b[B\x1b[2X\x1b8");
}
fn gradient_frames() -> Vec<RgbaImage> {
(0..2)
.map(|f| {
RgbaImage::from_fn(40, 36, |x, y| {
let s = (f * 7) as u32;
Rgba([
((x * 6 + s) % 256) as u8,
((y * 7 + s) % 256) as u8,
((x * 3 + y * 4 + s) % 256) as u8,
255,
])
})
})
.collect()
}
fn quantise_err(frames: &[RgbaImage], fac: i32) -> f64 {
let mut sample: Vec<u8> = Vec::new();
for f in frames {
sample.extend_from_slice(f.as_raw());
}
let nq = color_quant::NeuQuant::new(fac, PALETTE_COLORS as usize, &sample);
let map = nq.color_map_rgba();
let (mut sum, mut n) = (0.0f64, 0.0f64);
for f in frames {
for px in f.pixels() {
let i = nq.index_of(&px.0) * 4;
for c in 0..3 {
sum += (px.0[c] as f64 - map[i + c] as f64).abs();
n += 1.0;
}
}
}
sum / n
}
#[test]
fn train_factor_never_starves_a_small_emote() {
assert_eq!(train_factor(1_440 * 2), 1); assert_eq!(train_factor(43_000), 2);
assert_eq!(train_factor(5_000_000), 10); }
#[test]
fn small_emotes_quantise_near_full_quality() {
let frames = gradient_frames();
let px = frames.iter().map(|f| f.as_raw().len() / 4).sum::<usize>();
let ours = quantise_err(&frames, train_factor(px));
let starved = quantise_err(&frames, 10);
let best = quantise_err(&frames, 1);
assert_eq!(ours, best, "an emote-sized stack must train on every pixel");
assert!(
ours < starved,
"ours {ours:.2} should beat the fixed 1-in-10 sample {starved:.2}"
);
}
#[test]
fn erase_block_clears_each_row_and_restores_the_cursor() {
assert_eq!(erase_block(4, 1), "\x1b7\x1b[4X\x1b8");
assert_eq!(
erase_block(3, 3),
"\x1b7\x1b[3X\x1b[B\x1b[3X\x1b[B\x1b[3X\x1b8"
);
assert_eq!(erase_block(0, 2), "");
assert_eq!(erase_block(2, 0), "");
}
#[test]
fn fix_leaves_unwrapped_sixel_alone() {
use ratatui_image::protocol::sixel::Sixel;
let mut p = Protocol::Sixel(Sixel {
data: "\x1bP0;1;0q#0;2;100;50;0#0~~\x1b\\".into(),
area: Rect::new(0, 0, 2, 2),
is_tmux: false,
});
let Protocol::Sixel(orig) = &p else {
unreachable!()
};
let orig = orig.data.clone();
fix_tmux_passthrough(&mut p);
let Protocol::Sixel(after) = &p else {
unreachable!()
};
assert_eq!(after.data, orig);
}
#[test]
fn alpha_over_opaque_replaces() {
let mut base = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255])); let over = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 255])); alpha_over(&mut base, &over);
assert_eq!(base.get_pixel(0, 0), &Rgba([0, 0, 255, 255])); }
#[test]
fn alpha_over_transparent_keeps_base() {
let mut base = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255])); let over = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 0])); alpha_over(&mut base, &over);
assert_eq!(base.get_pixel(0, 0), &Rgba([255, 0, 0, 255])); }
#[test]
fn alpha_over_half_blends() {
let mut base = RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 255])); let over = RgbaImage::from_pixel(1, 1, Rgba([255, 255, 255, 128])); alpha_over(&mut base, &over);
let p = base.get_pixel(0, 0);
assert!((120..=136).contains(&p[0]), "blended ~half: {}", p[0]);
assert_eq!(p[3], 255); }
#[test]
fn sample_frame_walks_timeline() {
let f = |ms: u32| decode::Frame {
img: RgbaImage::new(1, 1),
delay_ms: ms,
};
let frames = vec![f(100), f(100), f(100)];
assert_eq!(sample_frame(&frames, 0), 0);
assert_eq!(sample_frame(&frames, 150), 1);
assert_eq!(sample_frame(&frames, 250), 2);
assert_eq!(sample_frame(&frames, 300), 0); }
#[test]
fn square_emote_gets_square_cell_block() {
assert_eq!(width_cells(1.0, 8, 16), 4);
assert_eq!(width_cells(1.0, 10, 20), 4);
assert_eq!(width_cells(1.0, 10, 10), 2);
}
#[test]
fn wide_emote_gets_more_columns() {
assert_eq!(width_cells(2.0, 8, 16), 8); assert_eq!(width_cells(0.5, 8, 16), 2); }
#[test]
fn absurd_aspect_is_clamped_not_unbounded() {
assert_eq!(width_cells(50.0, 8, 16), MAX_W);
assert_eq!(width_cells(0.0001, 8, 16), 1);
assert!((1..=MAX_W).contains(&width_cells(1.0, 0, 0)));
assert!((1..=MAX_W).contains(&width_cells(f32::NAN, 8, 16)));
assert!((1..=MAX_W).contains(&width_cells(f32::INFINITY, 8, 16)));
}
#[test]
fn fit_center_fills_exact_block_and_centres() {
let src = RgbaImage::from_pixel(32, 32, Rgba([255, 135, 0, 255]));
let out = fit_center(&src, 64, 32);
assert_eq!(out.dimensions(), (64, 32)); assert_eq!(out.get_pixel(0, 16)[3], 0); assert_eq!(out.get_pixel(63, 16)[3], 0); assert_eq!(out.get_pixel(32, 16), &Rgba([255, 135, 0, 255])); }
#[test]
fn fit_center_preserves_aspect_when_downscaling() {
let src = RgbaImage::from_pixel(128, 32, Rgba([255, 255, 255, 255]));
let out = fit_center(&src, 32, 32);
assert_eq!(out.dimensions(), (32, 32));
assert_eq!(out.get_pixel(16, 0)[3], 0); assert_eq!(out.get_pixel(16, 31)[3], 0); assert_eq!(out.get_pixel(16, 16)[3], 255); }
#[test]
fn fit_center_upscales_small_sources_to_the_block() {
let src = RgbaImage::from_pixel(16, 16, Rgba([1, 2, 3, 255]));
let out = fit_center(&src, 48, 48);
assert_eq!(out.dimensions(), (48, 48));
assert_eq!(out.get_pixel(24, 24)[3], 255); }
fn protos(delays: &[u32]) -> Vec<FrameProto> {
delays
.iter()
.map(|&delay_ms| FrameProto {
proto: Protocol::Halfblocks(Default::default()),
delay_ms,
})
.collect()
}
#[test]
fn frame_at_reports_index_and_time_to_flip() {
let f = protos(&[100, 100, 100]);
assert_eq!(frame_at(&f, 0, 300), (0, 100));
assert_eq!(frame_at(&f, 40, 300), (0, 60));
assert_eq!(frame_at(&f, 150, 300), (1, 50));
assert_eq!(frame_at(&f, 300, 300), (0, 100)); }
#[test]
fn static_image_never_schedules_a_redraw() {
assert_eq!(frame_at(&protos(&[0]), 12_345, 0).1, u64::MAX);
}
#[test]
fn subsample_caps_frames_and_keeps_duration() {
let frames: Vec<(RgbaImage, u32)> = (0..300).map(|_| (RgbaImage::new(1, 1), 20)).collect();
let out = subsample(frames, MAX_FRAMES);
assert_eq!(out.len(), MAX_FRAMES);
let total: u64 = out.iter().map(|f| f.1 as u64).sum();
assert!(
(5500..=6500).contains(&total),
"loop length preserved: {total}"
);
}
#[test]
fn subsample_leaves_short_animations_alone() {
let frames: Vec<(RgbaImage, u32)> = (0..10).map(|_| (RgbaImage::new(1, 1), 40)).collect();
assert_eq!(subsample(frames, MAX_FRAMES).len(), 10);
}
#[test]
fn stabilize_palette_makes_unchanged_regions_byte_identical() {
let mut a = RgbaImage::new(32, 32);
for (x, y, px) in a.enumerate_pixels_mut() {
*px = Rgba([(x * 7 % 256) as u8, (y * 5 % 256) as u8, 90, 255]);
}
let mut b = a.clone();
for x in 0..8 {
for y in 0..8 {
b.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let mut frames = vec![a, b];
stabilize_palette(&mut frames);
let (qa, qb) = (&frames[0], &frames[1]);
for y in 8..32 {
for x in 8..32 {
assert_eq!(qa.get_pixel(x, y), qb.get_pixel(x, y), "drift at {x},{y}");
}
}
}
#[test]
fn stabilize_palette_collapses_to_at_most_the_palette_size() {
let mut f = RgbaImage::new(64, 64);
for (x, y, px) in f.enumerate_pixels_mut() {
*px = Rgba([x as u8 * 4, y as u8 * 4, (x + y) as u8, 255]);
}
let mut frames = vec![f.clone(), f];
stabilize_palette(&mut frames);
let mut seen = std::collections::HashSet::new();
for px in frames[0].pixels() {
seen.insert([px[0], px[1], px[2]]);
}
assert!(
seen.len() <= PALETTE_COLORS as usize,
"{} colors",
seen.len()
);
}
#[test]
fn stabilize_palette_noop_on_single_frame() {
let f = RgbaImage::from_pixel(4, 4, Rgba([1, 2, 3, 255]));
let mut frames = vec![f.clone()];
stabilize_palette(&mut frames);
assert_eq!(frames[0], f);
}
#[test]
fn source_url_upgrades_only_when_the_block_needs_it() {
let u = "https://cdn.7tv.app/emote/ABC/1x.webp";
assert_eq!(source_url(u, 32), None); assert_eq!(
source_url(u, 40).as_deref(),
Some("https://cdn.7tv.app/emote/ABC/2x.webp")
);
assert_eq!(
source_url(u, 100).as_deref(),
Some("https://cdn.7tv.app/emote/ABC/4x.webp")
);
}
#[test]
fn source_url_handles_each_cdn_shape() {
assert_eq!(
source_url("https://cdn.betterttv.net/emote/ID/1x", 40).as_deref(),
Some("https://cdn.betterttv.net/emote/ID/2x")
);
assert_eq!(
source_url("https://cdn.frankerfacez.com/emote/1234/1", 40).as_deref(),
Some("https://cdn.frankerfacez.com/emote/1234/2")
);
assert_eq!(
source_url(
"https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0",
40
)
.as_deref(),
Some("https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/2.0")
);
}
#[test]
fn source_url_leaves_unknown_hosts_alone() {
assert_eq!(source_url("https://example.com/emote.png", 64), None);
}
}