use std::collections::HashMap;
use crate::base::Rect;
use crate::gfx::bitmap::Bitmap;
use crate::gfx::pipeline::{Channel, ExternalSink, ImageOutput, ImageRenderer, RenderedImage};
use crate::gfx::proto::kitty;
use crate::term::caps::{GraphicsCaps, WrapKind};
pub type SlotKey = u64;
#[derive(Clone, Debug)]
struct SlotState {
version: u64,
rect: Rect,
channel: Channel,
kitty_id: Option<u32>,
}
#[derive(Debug)]
pub enum SyncOutcome {
Unchanged,
Emitted(RenderedImage),
Cells(RenderedImage),
}
pub struct ImageSession {
renderer: ImageRenderer,
slots: HashMap<SlotKey, SlotState>,
kitty_transmits: u64,
kitty_places: u64,
kitty_deletes: u64,
}
impl Default for ImageSession {
fn default() -> Self {
ImageSession {
renderer: ImageRenderer::new(),
slots: HashMap::new(),
kitty_transmits: 0,
kitty_places: 0,
kitty_deletes: 0,
}
}
}
impl ImageSession {
pub fn new() -> ImageSession {
ImageSession::default()
}
pub fn renderer_mut(&mut self) -> &mut ImageRenderer {
&mut self.renderer
}
pub fn sync(
&mut self,
sink: &mut dyn ExternalSink,
key: SlotKey,
version: u64,
img: &Bitmap,
rect: Rect,
caps: &GraphicsCaps,
) -> SyncOutcome {
let channel = crate::gfx::pipeline::choose_channel(caps);
let prior = self.slots.get(&key).cloned();
let prior = match prior {
Some(p) if p.channel != channel => {
self.release(sink, key, caps);
None
}
p => p,
};
if let Some(prev) = &prior {
if prev.version == version && prev.rect == rect {
return SyncOutcome::Unchanged;
}
if channel == Channel::Kitty && prev.version == version {
if let Some(id) = prev.kitty_id {
let mut bytes = kitty::place(
id,
Some(rect.w as u32),
Some(rect.h as u32),
self.renderer.config.z,
);
bytes = wrap_for(caps, bytes);
sink.external_write(&bytes, rect.origin());
self.kitty_places += 1;
self.slots.insert(
key,
SlotState {
version,
rect,
channel,
kitty_id: Some(id),
},
);
return SyncOutcome::Emitted(RenderedImage {
channel,
output: ImageOutput::Bytes {
bytes: Vec::new(),
at: rect.origin(),
},
warnings: Vec::new(),
kitty_id: Some(id),
});
}
}
if channel == Channel::Kitty {
if let Some(id) = prev.kitty_id {
let bytes = wrap_for(caps, kitty::delete_by_id(id, true));
sink.external_write(&bytes, rect.origin());
self.kitty_deletes += 1;
}
}
}
let rendered = self.renderer.render(img, rect, caps);
match &rendered.output {
ImageOutput::Bytes { bytes, at } => {
sink.external_write(bytes, *at);
if channel == Channel::Kitty && rendered.kitty_id.is_some() {
self.kitty_transmits += 1;
}
self.slots.insert(
key,
SlotState {
version,
rect,
channel,
kitty_id: rendered.kitty_id,
},
);
SyncOutcome::Emitted(rendered)
}
ImageOutput::Cells(_) => {
self.slots.insert(
key,
SlotState {
version,
rect,
channel,
kitty_id: None,
},
);
SyncOutcome::Cells(rendered)
}
}
}
pub fn release(&mut self, sink: &mut dyn ExternalSink, key: SlotKey, caps: &GraphicsCaps) {
if let Some(state) = self.slots.remove(&key) {
if let (Channel::Kitty, Some(id)) = (state.channel, state.kitty_id) {
let bytes = wrap_for(caps, kitty::delete_by_id(id, true));
sink.external_write(&bytes, state.rect.origin());
self.kitty_deletes += 1;
}
}
}
pub fn release_all(&mut self, sink: &mut dyn ExternalSink, caps: &GraphicsCaps) {
let keys: Vec<SlotKey> = self.slots.keys().copied().collect();
for key in keys {
self.release(sink, key, caps);
}
}
pub fn live_slots(&self) -> usize {
self.slots.len()
}
pub fn live_kitty_ids(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self.slots.values().filter_map(|s| s.kitty_id).collect();
ids.sort_unstable();
ids
}
pub fn kitty_traffic(&self) -> (u64, u64, u64) {
(self.kitty_transmits, self.kitty_places, self.kitty_deletes)
}
pub fn check_invariants(&self) -> Result<(), String> {
let mut ids = Vec::new();
for (key, slot) in &self.slots {
match (slot.channel, slot.kitty_id) {
(Channel::Kitty, None) => {
return Err(format!("slot {key}: kitty slot without an id (unfreeable)"));
}
(Channel::Kitty, Some(id)) => ids.push(id),
(_, Some(id)) => {
return Err(format!(
"slot {key}: id {id} on a {:?} slot (no terminal-held state to name)",
slot.channel
));
}
(_, None) => {}
}
}
ids.sort_unstable();
if let Some(w) = ids.windows(2).find(|w| w[0] == w[1]) {
return Err(format!("kitty id {} owned by two slots", w[0]));
}
if self.kitty_deletes > self.kitty_transmits {
return Err(format!(
"delete accounting: {} deletes exceed {} transmits",
self.kitty_deletes, self.kitty_transmits
));
}
let live = ids.len() as u64;
let expected = self.kitty_transmits - self.kitty_deletes;
if live != expected {
return Err(format!(
"live accounting: {live} kitty slots but transmits({}) - deletes({}) = {expected}",
self.kitty_transmits, self.kitty_deletes
));
}
Ok(())
}
}
fn wrap_for(caps: &GraphicsCaps, bytes: Vec<u8>) -> Vec<u8> {
match caps.wrap {
Some(WrapKind::Tmux) => crate::term::tmux_wrap(&bytes),
None => bytes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::{Point, Rgba};
struct Sink(Vec<(Vec<u8>, Point)>);
impl ExternalSink for Sink {
fn external_write(&mut self, bytes: &[u8], at: Point) {
self.0.push((bytes.to_vec(), at));
}
}
fn kitty_caps() -> GraphicsCaps {
GraphicsCaps {
wrap: None,
kitty_graphics: true,
iterm2_images: false,
sixel: false,
sixel_max_registers: None,
cell_pixel_size: None,
}
}
fn img() -> Bitmap {
Bitmap::new(4, 4, Rgba::rgb(9, 9, 9))
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn kitty_lifecycle_transmit_place_delete() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let caps = kitty_caps();
let rect_a = Rect::new(1, 1, 8, 4);
let out = s.sync(&mut sink, 7, 1, &img(), rect_a, &caps);
assert!(matches!(out, SyncOutcome::Emitted(_)));
assert_eq!(sink.0.len(), 1);
assert!(text(&sink.0[0].0).contains("a=T"));
assert!(matches!(
s.sync(&mut sink, 7, 1, &img(), rect_a, &caps),
SyncOutcome::Unchanged
));
assert_eq!(sink.0.len(), 1);
let rect_b = Rect::new(10, 2, 6, 3);
let out = s.sync(&mut sink, 7, 1, &img(), rect_b, &caps);
assert!(matches!(out, SyncOutcome::Emitted(_)));
assert_eq!(sink.0.len(), 2);
let placed = text(&sink.0[1].0);
assert!(placed.contains("a=p") && placed.contains("i=1"), "{placed}");
assert!(!placed.contains("a=T"), "no retransmission on move");
assert_eq!(sink.0[1].1, Point::new(10, 2));
let out = s.sync(&mut sink, 7, 2, &img(), rect_b, &caps);
assert!(matches!(out, SyncOutcome::Emitted(_)));
assert_eq!(sink.0.len(), 4);
assert!(text(&sink.0[2].0).contains("d=I"), "free the stale upload");
assert!(text(&sink.0[3].0).contains("a=T"));
s.release(&mut sink, 7, &caps);
assert_eq!(sink.0.len(), 5);
assert!(text(&sink.0[4].0).contains("a=d"));
assert_eq!(s.live_slots(), 0);
}
#[test]
fn iterm2_reemits_on_every_change() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let caps = GraphicsCaps {
kitty_graphics: false,
iterm2_images: true,
..kitty_caps()
};
let rect = Rect::new(0, 0, 4, 2);
s.sync(&mut sink, 1, 1, &img(), rect, &caps);
assert_eq!(sink.0.len(), 1);
assert!(matches!(
s.sync(&mut sink, 1, 1, &img(), rect, &caps),
SyncOutcome::Unchanged
));
s.sync(&mut sink, 1, 1, &img(), Rect::new(5, 5, 4, 2), &caps);
assert_eq!(sink.0.len(), 2);
assert!(text(&sink.0[1].0).starts_with("\u{1b}]1337;File="));
s.release(&mut sink, 1, &caps);
assert_eq!(sink.0.len(), 2);
}
#[test]
fn mosaic_channel_returns_cells_and_tracks() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let caps = GraphicsCaps {
kitty_graphics: false,
..kitty_caps()
};
let out = s.sync(&mut sink, 3, 1, &img(), Rect::new(0, 0, 2, 1), &caps);
let SyncOutcome::Cells(r) = out else {
panic!("mosaic expected")
};
assert!(matches!(r.output, ImageOutput::Cells(ref c) if !c.is_empty()));
assert!(sink.0.is_empty(), "mosaic writes no bytes");
assert!(matches!(
s.sync(&mut sink, 3, 1, &img(), Rect::new(0, 0, 2, 1), &caps),
SyncOutcome::Unchanged
));
}
#[test]
fn channel_upgrade_resets_the_slot() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let mosaic_caps = GraphicsCaps {
kitty_graphics: false,
..kitty_caps()
};
s.sync(&mut sink, 9, 1, &img(), Rect::new(0, 0, 2, 1), &mosaic_caps);
let out = s.sync(
&mut sink,
9,
1,
&img(),
Rect::new(0, 0, 2, 1),
&kitty_caps(),
);
assert!(matches!(out, SyncOutcome::Emitted(_)));
assert!(text(&sink.0.last().unwrap().0).contains("a=T"));
}
#[test]
fn session_accounting_agrees_with_the_kitty_model() {
use crate::testing::kitty_model::KittyModel;
for wrapped in [false, true] {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let mut caps = kitty_caps();
if wrapped {
caps.wrap = Some(WrapKind::Tmux);
}
let mut model = if wrapped {
KittyModel::with_tmux_unwrap()
} else {
KittyModel::new()
};
let mut fed = 0usize;
let mut step = |s: &ImageSession, sink: &Sink, model: &mut KittyModel, what: &str| {
for (bytes, _) in &sink.0[fed..] {
model.feed(bytes);
}
fed = sink.0.len();
s.check_invariants()
.unwrap_or_else(|e| panic!("[{what}] {e}"));
assert_eq!(
s.live_kitty_ids(),
model.live_data_ids(),
"[{what} wrapped={wrapped}] session vs model disagree on held ids"
);
for id in s.live_kitty_ids() {
assert_eq!(
model.transmit_count(id),
1,
"[{what}] id {id} transmitted more than once"
);
}
};
s.sync(&mut sink, 1, 1, &img(), Rect::new(0, 0, 4, 2), &caps);
step(&s, &sink, &mut model, "first transmit");
s.sync(&mut sink, 2, 1, &img(), Rect::new(6, 0, 4, 2), &caps);
step(&s, &sink, &mut model, "second slot");
s.sync(&mut sink, 1, 1, &img(), Rect::new(0, 4, 4, 2), &caps);
step(&s, &sink, &mut model, "move (a=p)");
s.sync(&mut sink, 1, 2, &img(), Rect::new(0, 4, 4, 2), &caps);
step(&s, &sink, &mut model, "new version (delete+retransmit)");
s.release(&mut sink, 2, &caps);
step(&s, &sink, &mut model, "release slot 2");
s.release_all(&mut sink, &caps);
step(&s, &sink, &mut model, "release all");
assert_eq!(s.live_slots(), 0);
assert!(
model.live_data_ids().is_empty(),
"terminal still holds pixels"
);
let (t, p, d) = s.kitty_traffic();
assert_eq!(t, d, "every upload freed exactly once by the end");
assert!(p >= 1, "the move must have used a placement escape");
}
}
#[test]
fn invariant_checker_catches_broken_accounting() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let caps = kitty_caps();
s.sync(&mut sink, 1, 1, &img(), Rect::new(0, 0, 4, 2), &caps);
assert!(s.check_invariants().is_ok());
let forged = s.slots.get(&1).cloned().unwrap();
s.slots.insert(2, forged);
let err = s.check_invariants().unwrap_err();
assert!(err.contains("owned by two slots"), "{err}");
s.slots.remove(&2);
s.slots.get_mut(&1).unwrap().kitty_id = None;
let err = s.check_invariants().unwrap_err();
assert!(err.contains("without an id"), "{err}");
}
#[test]
fn tmux_wrap_covers_session_authored_escapes() {
let mut s = ImageSession::new();
let mut sink = Sink(Vec::new());
let mut caps = kitty_caps();
caps.wrap = Some(WrapKind::Tmux);
let rect = Rect::new(0, 0, 4, 2);
s.sync(&mut sink, 5, 1, &img(), rect, &caps);
s.sync(&mut sink, 5, 1, &img(), Rect::new(2, 2, 4, 2), &caps);
for (bytes, _) in &sink.0 {
assert!(
bytes.starts_with(b"\x1bPtmux;"),
"unwrapped escape reached the sink"
);
}
s.release(&mut sink, 5, &caps);
assert!(sink.0.last().unwrap().0.starts_with(b"\x1bPtmux;"));
}
}