use std::fmt::Write;
use base64::Engine as _;
use image::DynamicImage;
use ratatui::layout::Size;
const CHARS_PER_CHUNK: usize = 4096;
const CHUNK_SIZE: usize = (CHARS_PER_CHUNK / 4) * 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Geometry {
pub cells: Size,
pub font: (u16, u16),
}
impl Geometry {
pub fn new(cells: Size, font: (u16, u16)) -> Self {
Self { cells, font }
}
pub fn pixels(&self) -> (u32, u32) {
(
u32::from(self.cells.width) * u32::from(self.font.0),
u32::from(self.cells.height) * u32::from(self.font.1),
)
}
}
pub fn image_id(url: &str, geometry: Geometry) -> u32 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
geometry.cells.width.hash(&mut hasher);
geometry.cells.height.hash(&mut hasher);
geometry.font.hash(&mut hasher);
match hasher.finish() as u32 {
0 => 1,
id => id,
}
}
pub fn placement_id(block_idx: usize) -> u32 {
(block_idx as u32).saturating_add(1)
}
pub fn transmit(id: u32, image: &DynamicImage) -> String {
let rgba = image.to_rgba8();
let bytes = rgba.as_raw();
let (width, height) = (image.width(), image.height());
let chunk_count = bytes.len().div_ceil(CHUNK_SIZE);
let mut out = String::with_capacity(bytes.len() * 4 / 3 + chunk_count * 48);
if chunk_count == 0 {
return out;
}
for (i, chunk) in bytes.chunks(CHUNK_SIZE).enumerate() {
out.push_str("\x1b_Gq=2,");
if i == 0 {
write!(out, "i={id},a=t,f=32,t=d,s={width},v={height},").unwrap();
}
let more = u8::from(i + 1 < chunk_count);
write!(out, "m={more};").unwrap();
base64::engine::general_purpose::STANDARD.encode_string(chunk, &mut out);
out.push_str("\x1b\\");
}
out
}
pub fn place(
id: u32,
placement_id: u32,
geometry: Geometry,
skip: u16,
dst: ratatui::layout::Rect,
transmit: Option<&str>,
) -> String {
let (pixels_w, _) = geometry.pixels();
let font_h = u32::from(geometry.font.1);
let src_y = u32::from(skip) * font_h;
let src_h = u32::from(dst.height) * font_h;
let mut out =
String::with_capacity(transmit.map_or(0, str::len) + usize::from(dst.height) * 16 + 96);
for row in 0..dst.height {
write!(
out,
"\x1b[{};{}H\x1b[{}X",
dst.y + row + 1,
dst.x + 1,
dst.width
)
.unwrap();
}
if let Some(transmit) = transmit {
out.push_str(transmit);
}
write!(
out,
"\x1b[{};{}H\x1b_Gq=2,i={id},p={placement_id},a=p,x=0,y={src_y},w={pixels_w},h={src_h},c={},r={},C=1\x1b\\",
dst.y + 1,
dst.x + 1,
dst.width,
dst.height
)
.unwrap();
write!(out, "\x1b[{};{}H", dst.y + 1, dst.x + 2).unwrap();
out
}
pub fn delete_placement(id: u32, placement_id: u32) -> String {
format!("\x1b_Gq=2,i={id},p={placement_id},a=d,d=i\x1b\\")
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
fn geometry() -> Geometry {
Geometry::new(Size::new(40, 30), (8, 16))
}
#[test]
fn pixels_are_the_cell_size_times_the_font() {
assert_eq!(geometry().pixels(), (320, 480));
}
#[test]
fn the_image_id_is_per_url_and_geometry_and_never_zero() {
let small = geometry();
let wider = Geometry::new(Size::new(80, 30), (8, 16));
assert_eq!(
image_id("https://example.com/a.png", small),
image_id("https://example.com/a.png", small)
);
assert_ne!(
image_id("https://example.com/a.png", small),
image_id("https://example.com/b.png", small)
);
assert_ne!(
image_id("https://example.com/a.png", small),
image_id("https://example.com/a.png", wider)
);
assert_ne!(
image_id("a.png", small),
image_id("a.png", Geometry::new(small.cells, (9, 16)))
);
assert_ne!(image_id("", small), 0);
assert_ne!(image_id("some/path.png", small), 0);
}
#[test]
fn the_placement_id_is_per_block_and_never_zero() {
assert_ne!(placement_id(0), 0);
assert_ne!(placement_id(1), 0);
assert_ne!(placement_id(0), placement_id(1));
assert_ne!(placement_id(7), placement_id(8));
}
#[test]
fn the_transmit_chunks_and_marks_every_chunk_but_the_last() {
let image = DynamicImage::ImageRgba8(image::ImageBuffer::new(1, 1));
let seq = transmit(7, &image);
assert!(
seq.starts_with("\x1b_Gq=2,i=7,a=t,f=32,t=d,s=1,v=1,m=0;"),
"{seq:?}"
);
assert!(seq.ends_with("\x1b\\"), "{seq:?}");
assert_eq!(seq.matches("\x1b_G").count(), 1);
let image = DynamicImage::ImageRgba8(image::ImageBuffer::new(1024, 1));
let seq = transmit(7, &image);
assert_eq!(seq.matches("\x1b_G").count(), 2);
assert_eq!(seq.matches("m=1;").count(), 1);
assert_eq!(seq.matches("m=0;").count(), 1);
assert_eq!(seq.matches("a=t").count(), 1);
assert_eq!(seq.matches("s=1024,v=1").count(), 1);
}
#[test]
fn a_zero_pixel_image_transmits_nothing() {
let image = DynamicImage::ImageRgba8(image::ImageBuffer::new(0, 0));
assert!(transmit(7, &image).is_empty());
}
#[test]
fn the_band_is_the_source_rectangle_of_the_full_width() {
let dst = Rect::new(3, 11, 40, 4);
let symbol = place(9, 2, geometry(), 5, dst, None);
assert!(
symbol.contains(",x=0,y=80,w=320,h=64,c=40,r=4,"),
"the source rect must be the band, in image pixels: {symbol:?}"
);
}
#[test]
fn the_escape_erases_each_row_before_placing_and_restores_the_cursor() {
let dst = Rect::new(3, 11, 40, 3);
let symbol = place(9, 2, geometry(), 0, dst, None);
let first_place = symbol.find("a=p").expect("a placement");
for row in 0..3u16 {
let erase = format!("\x1b[{};4H\x1b[40X", 12 + row);
let at = symbol
.find(&erase)
.unwrap_or_else(|| panic!("missing the erase sweep for row {row}: {symbol:?}"));
assert!(at < first_place, "erase sweeps must precede the placement");
}
assert!(symbol.ends_with("\x1b[12;5H"), "{symbol:?}");
assert!(symbol.contains(",C=1\x1b\\"), "{symbol:?}");
}
#[test]
fn the_transmit_is_carried_once_and_before_the_placement() {
let dst = Rect::new(0, 0, 40, 2);
let symbol = place(
9,
2,
geometry(),
0,
dst,
Some("\x1b_Gq=2,i=9,a=t,m=0;AAAA\x1b\\"),
);
let transmit_at = symbol.find("a=t").expect("the transmit");
let place_at = symbol.find("a=p").expect("the placement");
assert!(
transmit_at < place_at,
"data must precede the placement that needs it"
);
let re_placed = place(9, 2, geometry(), 0, dst, None);
assert!(!re_placed.contains("a=t"), "{re_placed:?}");
assert!(re_placed.contains("a=p"), "{re_placed:?}");
}
#[test]
fn the_delete_names_one_placement_and_keeps_the_image_data() {
let seq = delete_placement(9, 3);
assert!(seq.contains("a=d"), "{seq:?}");
assert!(seq.contains("d=i"), "{seq:?}");
assert!(seq.contains("i=9"), "{seq:?}");
assert!(seq.contains("p=3"), "{seq:?}");
assert!(!seq.contains("d=I"), "{seq:?}");
}
}