fix: kitty graphics streams freeze on partial updates and leak host images (#948)
* fix: request a render on kitty graphics writes pty output containing a kitty graphics sequence only scheduled the deferred settle render. that render's wake-up notifies the render loop only when the dirty flag was not already set, so under a continuous stream of image updates the notify can be dropped and the pane keeps showing an old frame until something else forces a full render. request an immediate render like any other output. the dirty flag and the minimum render interval still coalesce chunked writes, and the settle render stays as a backstop. refs #947 * fix: fingerprint kitty images exactly across retransmissions the image fingerprint hashed only the first, middle, and last 4096 bytes of pixel data. a program that streams updates by retransmitting one image id keeps the length and dimensions constant, so a frame whose changed pixels all fall outside those three windows gets the same fingerprint as the frame before it. the encoder then treats the image as already uploaded, emits no graphics bytes, and the frame is dropped as identical to the previous render. the pane stays stuck on the last uploaded image until a resize resets the graphics surface. hash the full payload instead. to keep that off the render hot path, cache the fingerprint per image id and recompute it only when the image's transmit time changes. libghostty-vt already refreshes the transmit time on every transmission; vendor patch 0002 exposes it through a new GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS accessor. a static image costs one map lookup per render and a streaming image one hash per transmitted frame. refs #947 * fix: delete superseded host kitty images host image ids derive from image content, so a pane image whose pixels change maps to a new host id on every update. nothing deleted the host image it replaced, and a streaming pane left one full-size image behind in the host terminal per changed frame until the graphics surface was reset. track which host image backs each pane image and delete the replaced one once no source references it. refs #947 * fix: prune stale kitty graphics source entries a source entry whose pane closed or whose placement disappeared stayed in the cache forever. through the shared-reference check it could keep an old host image alive that nothing visible used anymore, and the entries themselves accumulated without bound. prune sources against the visible set at the start of every encode pass. refs #947 * docs: link issue and upstream discussion for vendor patch 0002 the transmit time accessor now has a removal path: the ghostty discussion proposing it upstream is linked in the patch ledger. refs #947 --------- Co-authored-by: Can Celik <ogulcancelik@gmail.com>
This commit is contained in:
parent
5abdae1ac7
commit
4eceb75d8a
|
|
@ -2699,6 +2699,8 @@ pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR:
|
|||
GhosttyKittyGraphicsImageData = 7;
|
||||
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN:
|
||||
GhosttyKittyGraphicsImageData = 8;
|
||||
pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS:
|
||||
GhosttyKittyGraphicsImageData = 9;
|
||||
|
||||
pub type GhosttyKittyImageFormat = ::std::os::raw::c_uint;
|
||||
pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGB: GhosttyKittyImageFormat = 0;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
pub mod bindings;
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::c_void;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
|
@ -21,7 +21,7 @@ use std::ops::RangeInclusive;
|
|||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
use std::slice;
|
||||
use std::sync::{Once, OnceLock};
|
||||
use std::sync::{Mutex, Once, OnceLock};
|
||||
|
||||
pub use bindings as ffi;
|
||||
|
||||
|
|
@ -170,10 +170,6 @@ const TERMINAL_DATA_COLOR_CURSOR: ffi::GhosttyTerminalData = 20;
|
|||
const KITTY_IMAGE_STORAGE_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const APC_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024;
|
||||
// Kitty image fingerprints are used as a display cache key, not a
|
||||
// cryptographic identity. Sampling keeps redraws cheap for multi-megabyte
|
||||
// images while still distinguishing normal screenshots/photos/diagrams.
|
||||
const KITTY_FINGERPRINT_SAMPLE_BYTES: usize = 4096;
|
||||
pub(crate) const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE;
|
||||
// The vendored C headers expose these placement fields, but the checked-in
|
||||
// generated bindings predate the names. Keep the explicit values aligned with
|
||||
|
|
@ -219,6 +215,14 @@ pub struct KittyImageDescriptor {
|
|||
pub data_fingerprint: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct KittyImageFingerprintEntry {
|
||||
transmit_time_ns: u64,
|
||||
data_ptr: usize,
|
||||
data_len: usize,
|
||||
fingerprint: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KittyPlacementRenderInfo {
|
||||
pub pixel_width: u32,
|
||||
|
|
@ -568,6 +572,7 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
|
|||
pub struct Terminal {
|
||||
raw: ffi::GhosttyTerminal_ptr,
|
||||
write_pty_callback: Option<Box<WritePtyCallbackState>>,
|
||||
kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
|
|
@ -585,6 +590,7 @@ impl Terminal {
|
|||
Ok(Self {
|
||||
raw,
|
||||
write_pty_callback: None,
|
||||
kitty_fingerprints: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1139,9 +1145,69 @@ impl Terminal {
|
|||
}
|
||||
placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?);
|
||||
placements.sort_by_key(|placement| placement.z);
|
||||
self.prune_kitty_fingerprints(&placements);
|
||||
Ok(placements)
|
||||
}
|
||||
|
||||
/// Fingerprint for `image`, cached per image id and recomputed only when
|
||||
/// the image's transmit time (or data identity) changes.
|
||||
fn kitty_image_fingerprint_cached(
|
||||
&self,
|
||||
image: ffi::GhosttyKittyGraphicsImage,
|
||||
image_id: u32,
|
||||
data: (*const u8, usize),
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
format: KittyImageFormat,
|
||||
) -> u64 {
|
||||
let (data_ptr, data_len) = data;
|
||||
let Ok(transmit_time_ns) = kitty_image_u64(
|
||||
image,
|
||||
ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS,
|
||||
) else {
|
||||
return kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
|
||||
};
|
||||
|
||||
if let Ok(cache) = self.kitty_fingerprints.lock() {
|
||||
if let Some(entry) = cache.get(&image_id) {
|
||||
if entry.transmit_time_ns == transmit_time_ns
|
||||
&& entry.data_ptr == data_ptr as usize
|
||||
&& entry.data_len == data_len
|
||||
{
|
||||
return entry.fingerprint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fingerprint =
|
||||
kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
|
||||
if let Ok(mut cache) = self.kitty_fingerprints.lock() {
|
||||
cache.insert(
|
||||
image_id,
|
||||
KittyImageFingerprintEntry {
|
||||
transmit_time_ns,
|
||||
data_ptr: data_ptr as usize,
|
||||
data_len,
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
}
|
||||
fingerprint
|
||||
}
|
||||
|
||||
fn prune_kitty_fingerprints(&self, placements: &[KittyImagePlacement]) {
|
||||
if let Ok(mut cache) = self.kitty_fingerprints.lock() {
|
||||
if cache.is_empty() {
|
||||
return;
|
||||
}
|
||||
let live: HashSet<u32> = placements
|
||||
.iter()
|
||||
.map(|placement| placement.image_id)
|
||||
.collect();
|
||||
cache.retain(|image_id, _| live.contains(image_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn kitty_image_placement<F>(
|
||||
&self,
|
||||
graphics: ffi::GhosttyKittyGraphics,
|
||||
|
|
@ -1198,8 +1264,14 @@ impl Terminal {
|
|||
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
|
||||
)?;
|
||||
let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
|
||||
let data_fingerprint =
|
||||
kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
|
||||
let data_fingerprint = self.kitty_image_fingerprint_cached(
|
||||
image,
|
||||
image_id,
|
||||
(data_ptr, data_len),
|
||||
image_width,
|
||||
image_height,
|
||||
format,
|
||||
);
|
||||
let descriptor = KittyImageDescriptor {
|
||||
image_id,
|
||||
placement_id,
|
||||
|
|
@ -1336,8 +1408,14 @@ impl Terminal {
|
|||
};
|
||||
let placement_id = run.synthetic_placement_id();
|
||||
let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
|
||||
let data_fingerprint =
|
||||
kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
|
||||
let data_fingerprint = self.kitty_image_fingerprint_cached(
|
||||
image,
|
||||
image_id,
|
||||
(data_ptr, data_len),
|
||||
image_width,
|
||||
image_height,
|
||||
format,
|
||||
);
|
||||
let descriptor = KittyImageDescriptor {
|
||||
image_id,
|
||||
placement_id,
|
||||
|
|
@ -1750,6 +1828,18 @@ fn kitty_image_u32(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn kitty_image_u64(
|
||||
image: ffi::GhosttyKittyGraphicsImage,
|
||||
data: ffi::GhosttyKittyGraphicsImageData,
|
||||
) -> Result<u64, Error> {
|
||||
let mut out = 0u64;
|
||||
unsafe {
|
||||
ffi::ghostty_kitty_graphics_image_get(image, data, (&mut out as *mut u64).cast())
|
||||
.into_result()?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn kitty_image_format(image: ffi::GhosttyKittyGraphicsImage) -> Result<KittyImageFormat, Error> {
|
||||
let mut out = ffi::GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA;
|
||||
unsafe {
|
||||
|
|
@ -1812,6 +1902,8 @@ fn kitty_image_data_from_ptr(ptr_out: *const u8, len: usize) -> Vec<u8> {
|
|||
unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec()
|
||||
}
|
||||
|
||||
// Hashes the full payload. Callers cache the result per image id and only
|
||||
// recompute it when the image's transmit time changes.
|
||||
fn kitty_image_fingerprint(
|
||||
ptr_out: *const u8,
|
||||
len: usize,
|
||||
|
|
@ -1829,23 +1921,10 @@ fn kitty_image_fingerprint(
|
|||
}
|
||||
|
||||
let data = unsafe { slice::from_raw_parts(ptr_out, len) };
|
||||
hash_kitty_image_data_sample(&mut hasher, data);
|
||||
data.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn hash_kitty_image_data_sample(hasher: &mut impl Hasher, data: &[u8]) {
|
||||
let sample = KITTY_FINGERPRINT_SAMPLE_BYTES;
|
||||
if data.len() <= sample * 3 {
|
||||
data.hash(hasher);
|
||||
return;
|
||||
}
|
||||
|
||||
data[..sample].hash(hasher);
|
||||
let middle_start = (data.len() / 2).saturating_sub(sample / 2);
|
||||
data[middle_start..middle_start + sample].hash(hasher);
|
||||
data[data.len() - sample..].hash(hasher);
|
||||
}
|
||||
|
||||
fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<String>, Error> {
|
||||
let mut required = 0usize;
|
||||
let result =
|
||||
|
|
@ -2735,29 +2814,39 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_image_fingerprint_samples_large_payloads() {
|
||||
let mut data = vec![1u8; KITTY_FINGERPRINT_SAMPLE_BYTES * 4];
|
||||
fn kitty_image_fingerprint_covers_full_payload() {
|
||||
let mut data = vec![1u8; 4096 * 4];
|
||||
let original =
|
||||
kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
|
||||
|
||||
data[KITTY_FINGERPRINT_SAMPLE_BYTES / 2] = 2;
|
||||
let changed_prefix =
|
||||
data[4096 + 123] = 2;
|
||||
let changed_outside_sampled_windows =
|
||||
kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
|
||||
assert_ne!(original, changed_prefix);
|
||||
assert_ne!(original, changed_outside_sampled_windows);
|
||||
}
|
||||
|
||||
data[KITTY_FINGERPRINT_SAMPLE_BYTES / 2] = 1;
|
||||
let middle = data.len() / 2;
|
||||
data[middle] = 3;
|
||||
let changed_middle =
|
||||
kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
|
||||
assert_ne!(original, changed_middle);
|
||||
#[test]
|
||||
fn kitty_image_fingerprint_refreshes_on_retransmission() {
|
||||
let mut terminal = Terminal::new(10, 5, 0).unwrap();
|
||||
terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\");
|
||||
let first = terminal
|
||||
.kitty_image_placements_with_data_filter(|_| true)
|
||||
.unwrap();
|
||||
assert_eq!(first.len(), 1);
|
||||
|
||||
data[middle] = 1;
|
||||
let last = data.len() - 1;
|
||||
data[last] = 4;
|
||||
let changed_suffix =
|
||||
kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
|
||||
assert_ne!(original, changed_suffix);
|
||||
// Same id and size, different pixels.
|
||||
terminal.write(b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;AAAAAA==\x1b\\");
|
||||
let second = terminal
|
||||
.kitty_image_placements_with_data_filter(|_| true)
|
||||
.unwrap();
|
||||
assert_eq!(second.len(), 1);
|
||||
assert_ne!(first[0].data_fingerprint, second[0].data_fingerprint);
|
||||
|
||||
// No retransmission, so the fingerprint stays stable across renders.
|
||||
let third = terminal
|
||||
.kitty_image_placements_with_data_filter(|_| true)
|
||||
.unwrap();
|
||||
assert_eq!(second[0].data_fingerprint, third[0].data_fingerprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ struct ClippedPlacement {
|
|||
pub(crate) struct HostGraphicsCache {
|
||||
images: HashMap<u32, ImageSignature>,
|
||||
placements: HashMap<(u32, u32), PlacementSignature>,
|
||||
/// Host image currently backing each (pane, source image id) pair.
|
||||
sources: HashMap<(PaneId, u32), u32>,
|
||||
view: Option<HostViewKey>,
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +206,7 @@ pub(crate) fn encode_local_pane_graphics(
|
|||
view_changed,
|
||||
&mut cache.images,
|
||||
&mut cache.placements,
|
||||
&mut cache.sources,
|
||||
);
|
||||
tracing::debug!(
|
||||
placements = placements.len(),
|
||||
|
|
@ -267,7 +270,16 @@ fn encode_graphics_update(
|
|||
view_changed: bool,
|
||||
host_images: &mut HashMap<u32, ImageSignature>,
|
||||
host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
|
||||
sources: &mut HashMap<(PaneId, u32), u32>,
|
||||
) {
|
||||
// Prune sources that are no longer visible: a stale entry would keep its
|
||||
// old host image referenced and block the superseded-image delete.
|
||||
let current_sources: HashSet<(PaneId, u32)> = placements
|
||||
.iter()
|
||||
.map(|placement| (placement.pane_id, placement.placement.image_id))
|
||||
.collect();
|
||||
sources.retain(|source, _| current_sources.contains(source));
|
||||
|
||||
let mut current_placements = HashSet::new();
|
||||
for placement in placements {
|
||||
let clipped = clipped_placement(placement);
|
||||
|
|
@ -318,6 +330,16 @@ fn encode_graphics_update(
|
|||
}
|
||||
}
|
||||
|
||||
release_superseded_source_image(
|
||||
bytes,
|
||||
sources,
|
||||
host_images,
|
||||
host_placements,
|
||||
&mut current_placements,
|
||||
(placement.pane_id, placement.placement.image_id),
|
||||
host_id,
|
||||
);
|
||||
|
||||
// A different view can repaint the same cells with text or overlays and
|
||||
// leave the host-side Kitty placement state out of sync with this cache.
|
||||
// Re-emit the placement even when its geometry signature is unchanged.
|
||||
|
|
@ -359,6 +381,36 @@ fn encode_graphics_update(
|
|||
}
|
||||
}
|
||||
|
||||
/// Records that `source` is now backed by `host_id` and deletes the host
|
||||
/// image it previously pointed at once no other source references it.
|
||||
fn release_superseded_source_image(
|
||||
bytes: &mut Vec<u8>,
|
||||
sources: &mut HashMap<(PaneId, u32), u32>,
|
||||
host_images: &mut HashMap<u32, ImageSignature>,
|
||||
host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
|
||||
current_placements: &mut HashSet<(u32, u32)>,
|
||||
source: (PaneId, u32),
|
||||
host_id: u32,
|
||||
) {
|
||||
let Some(previous) = sources.insert(source, host_id) else {
|
||||
return;
|
||||
};
|
||||
if previous == host_id || sources.values().any(|id| *id == previous) {
|
||||
return;
|
||||
}
|
||||
encode_delete_image(bytes, previous);
|
||||
host_images.remove(&previous);
|
||||
// The `d=I` delete also removes the image's placements host-side.
|
||||
host_placements.retain(|(image_id, placement_id), _| {
|
||||
if *image_id == previous {
|
||||
current_placements.remove(&(*image_id, *placement_id));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn clear_all_host_graphics() -> io::Result<()> {
|
||||
let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default()));
|
||||
let mut bytes = Vec::new();
|
||||
|
|
@ -399,6 +451,7 @@ impl HostGraphicsCache {
|
|||
}
|
||||
self.images.clear();
|
||||
self.placements.clear();
|
||||
self.sources.clear();
|
||||
self.view = None;
|
||||
bytes
|
||||
}
|
||||
|
|
@ -865,6 +918,7 @@ mod tests {
|
|||
fn graphics_update_uploads_once_then_repositions_only() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let placement = test_placement(0, 0);
|
||||
|
||||
|
|
@ -874,6 +928,7 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let first = String::from_utf8_lossy(&bytes);
|
||||
assert!(first.contains("a=t"));
|
||||
|
|
@ -881,7 +936,14 @@ mod tests {
|
|||
|
||||
bytes.clear();
|
||||
let same = test_placement(0, 0);
|
||||
encode_graphics_update(&mut bytes, &[same], false, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[same],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert!(bytes.is_empty());
|
||||
|
||||
let mut z_changed = test_placement(0, 0);
|
||||
|
|
@ -892,6 +954,7 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let z_changed_bytes = String::from_utf8_lossy(&bytes);
|
||||
assert!(!z_changed_bytes.contains("a=t"));
|
||||
|
|
@ -899,7 +962,14 @@ mod tests {
|
|||
|
||||
bytes.clear();
|
||||
let moved = test_placement(0, 1);
|
||||
encode_graphics_update(&mut bytes, &[moved], false, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[moved],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let moved_bytes = String::from_utf8_lossy(&bytes);
|
||||
assert!(!moved_bytes.contains("a=t"));
|
||||
assert!(moved_bytes.contains("a=p"));
|
||||
|
|
@ -909,6 +979,7 @@ mod tests {
|
|||
fn view_change_redisplays_unchanged_visible_placement() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let placement = test_placement(0, 0);
|
||||
|
||||
|
|
@ -918,12 +989,20 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(placements.len(), 1);
|
||||
|
||||
bytes.clear();
|
||||
let same = test_placement(0, 0);
|
||||
encode_graphics_update(&mut bytes, &[same], true, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[same],
|
||||
true,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let redisplay = String::from_utf8_lossy(&bytes);
|
||||
assert!(!redisplay.contains("a=t"));
|
||||
assert!(redisplay.contains("a=p"));
|
||||
|
|
@ -942,6 +1021,7 @@ mod tests {
|
|||
false,
|
||||
&mut cache.images,
|
||||
&mut cache.placements,
|
||||
&mut cache.sources,
|
||||
);
|
||||
assert_eq!(cache.images.len(), 1);
|
||||
assert_eq!(cache.placements.len(), 1);
|
||||
|
|
@ -954,6 +1034,7 @@ mod tests {
|
|||
false,
|
||||
&mut cache.images,
|
||||
&mut cache.placements,
|
||||
&mut cache.sources,
|
||||
);
|
||||
|
||||
let redisplay = String::from_utf8_lossy(&bytes);
|
||||
|
|
@ -968,6 +1049,7 @@ mod tests {
|
|||
fn scrollback_offset_change_redisplays_placement() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let placement = test_placement(0, 0);
|
||||
|
||||
|
|
@ -977,12 +1059,20 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
bytes.clear();
|
||||
let mut scrolled = test_placement(0, 0);
|
||||
scrolled.scrollback_offset = 3;
|
||||
encode_graphics_update(&mut bytes, &[scrolled], false, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[scrolled],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let redisplay = String::from_utf8_lossy(&bytes);
|
||||
assert!(!redisplay.contains("a=t"));
|
||||
assert!(redisplay.contains("a=p"));
|
||||
|
|
@ -992,6 +1082,7 @@ mod tests {
|
|||
fn empty_image_data_does_not_mark_image_uploaded() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let mut placement = test_placement(0, 0);
|
||||
placement.placement.data.clear();
|
||||
|
|
@ -1002,6 +1093,7 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
assert!(bytes.is_empty());
|
||||
|
|
@ -1013,10 +1105,18 @@ mod tests {
|
|||
fn same_image_signature_reuses_host_upload_across_source_image_ids() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let first = test_placement(0, 0);
|
||||
|
||||
encode_graphics_update(&mut bytes, &[first], false, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[first],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(placements.len(), 1);
|
||||
|
||||
|
|
@ -1031,6 +1131,7 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
let reused = String::from_utf8_lossy(&bytes);
|
||||
|
|
@ -1040,10 +1141,147 @@ mod tests {
|
|||
assert_eq!(placements.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_image_content_deletes_superseded_host_image() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let first = test_placement(0, 0);
|
||||
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[first],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
let superseded_host_id = *images.keys().next().expect("uploaded host image");
|
||||
|
||||
// Same source image id, new pixel content: the fresh content maps to
|
||||
// a fresh host image id, so the replaced one must be deleted.
|
||||
bytes.clear();
|
||||
let mut changed = test_placement(0, 0);
|
||||
changed.placement.data_fingerprint = 43;
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[changed],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
let update = String::from_utf8_lossy(&bytes);
|
||||
assert!(update.contains("a=t"), "changed content re-uploads");
|
||||
assert!(
|
||||
update.contains(&format!("a=d,d=I,i={superseded_host_id}")),
|
||||
"superseded host image is deleted"
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(placements.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_host_image_survives_while_another_source_references_it() {
|
||||
fn twin_placement() -> HostPlacement {
|
||||
let mut twin = test_placement(5, 5);
|
||||
twin.placement.image_id = 8;
|
||||
twin.placement.placement_id = 4;
|
||||
twin
|
||||
}
|
||||
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[test_placement(0, 0), twin_placement()],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(images.len(), 1, "same content dedups to one host image");
|
||||
|
||||
// One source moves to new content while the other still shows the
|
||||
// old image: the shared host image must survive.
|
||||
bytes.clear();
|
||||
let mut changed = test_placement(0, 0);
|
||||
changed.placement.data_fingerprint = 43;
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[changed, twin_placement()],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
let update = String::from_utf8_lossy(&bytes);
|
||||
assert!(!update.contains("a=d,d=I"), "shared host image survives");
|
||||
assert_eq!(images.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_source_entry_does_not_block_superseded_image_delete() {
|
||||
fn twin_placement() -> HostPlacement {
|
||||
let mut twin = test_placement(5, 5);
|
||||
twin.placement.image_id = 8;
|
||||
twin.placement.placement_id = 4;
|
||||
twin
|
||||
}
|
||||
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[test_placement(0, 0), twin_placement()],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(sources.len(), 2);
|
||||
let shared_host_id = *images.keys().next().expect("uploaded host image");
|
||||
|
||||
// The twin source is gone and the survivor changed content: the
|
||||
// vanished source's stale entry must not keep the old host image
|
||||
// alive.
|
||||
bytes.clear();
|
||||
let mut changed = test_placement(0, 0);
|
||||
changed.placement.data_fingerprint = 43;
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[changed],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
let update = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
update.contains(&format!("a=d,d=I,i={shared_host_id}")),
|
||||
"old host image is deleted once its last live source moves on"
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(sources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_placement_deletes_placement_not_image_immediately() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let placement = test_placement(0, 0);
|
||||
|
||||
|
|
@ -1053,11 +1291,19 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
assert_eq!(placements.len(), 1);
|
||||
|
||||
bytes.clear();
|
||||
encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[],
|
||||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
let delete = String::from_utf8_lossy(&bytes);
|
||||
assert!(delete.contains("a=d,d=i"));
|
||||
assert!(!delete.contains("d=I"));
|
||||
|
|
@ -1069,6 +1315,7 @@ mod tests {
|
|||
fn view_change_deletes_stale_placement_immediately() {
|
||||
let mut images = HashMap::new();
|
||||
let mut placements = HashMap::new();
|
||||
let mut sources = HashMap::new();
|
||||
let mut bytes = Vec::new();
|
||||
let placement = test_placement(0, 0);
|
||||
|
||||
|
|
@ -1078,9 +1325,17 @@ mod tests {
|
|||
false,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
bytes.clear();
|
||||
encode_graphics_update(&mut bytes, &[], true, &mut images, &mut placements);
|
||||
encode_graphics_update(
|
||||
&mut bytes,
|
||||
&[],
|
||||
true,
|
||||
&mut images,
|
||||
&mut placements,
|
||||
&mut sources,
|
||||
);
|
||||
|
||||
let delete = String::from_utf8_lossy(&bytes);
|
||||
assert!(delete.contains("a=d,d=i"));
|
||||
|
|
|
|||
|
|
@ -613,7 +613,7 @@ impl GhosttyPaneTerminal {
|
|||
reported_cwd
|
||||
};
|
||||
|
||||
let request_render = !synchronized_output && !has_kitty_graphics_sequence;
|
||||
let request_render = !synchronized_output;
|
||||
let render_delay = render_delay_after_pty_write(
|
||||
synchronized_output,
|
||||
has_kitty_graphics_sequence,
|
||||
|
|
@ -3513,6 +3513,25 @@ mod tests {
|
|||
assert!(end.request_render);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_graphics_write_requests_render_with_settle_backstop() {
|
||||
crate::kitty_graphics::set_enabled(true);
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
|
||||
let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
|
||||
let pane_id = PaneId::from_raw(1);
|
||||
|
||||
let result = pane_terminal.process_pty_bytes(
|
||||
pane_id,
|
||||
0,
|
||||
b"\x1b_Ga=T,f=32,t=d,i=7,p=1,s=1,v=1,q=2;/wAA/w==\x1b\\",
|
||||
&tx,
|
||||
);
|
||||
|
||||
assert!(result.request_render);
|
||||
assert_eq!(result.render_delay, Some(KITTY_GRAPHICS_REDRAW_SETTLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_history_is_rendered_on_next_draw() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
|
|
|||
|
|
@ -38,3 +38,45 @@ verification:
|
|||
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="resize shrinks both axes with cursor at bottom"
|
||||
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="PageList resize less rows and cols cursor at bottom"
|
||||
```
|
||||
|
||||
## 0002 expose kitty image transmit time in the C API
|
||||
|
||||
status: active
|
||||
|
||||
patch: `vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch`
|
||||
|
||||
herdr issue: https://github.com/ogulcancelik/herdr/issues/947
|
||||
|
||||
upstream discussion: https://github.com/ghostty-org/ghostty/discussions/13177
|
||||
(proposes extending the kitty graphics inspection C API from
|
||||
https://github.com/ghostty-org/ghostty/pull/12145, which has no transmit
|
||||
time/serial accessor yet)
|
||||
|
||||
introduced upstream: not yet
|
||||
|
||||
vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8`
|
||||
|
||||
local files:
|
||||
|
||||
- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h`
|
||||
- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig`
|
||||
|
||||
reason: herdr fingerprints kitty image data to decide when to re-encode an
|
||||
image for render clients. Hashing the full payload on every render is too
|
||||
expensive for multi-megabyte images, and sampling windows misses small
|
||||
changes, freezing streaming sources. The image's transmit time already
|
||||
refreshes on every (re)transmission, so exposing it as
|
||||
`GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS` gives herdr an exact, O(1) change
|
||||
serial to invalidate a cached full-data fingerprint.
|
||||
|
||||
remove when: the vendored source commit exposes the image transmit time (or an
|
||||
equivalent transmission serial) in the C API and
|
||||
`ghostty::tests::kitty_image_fingerprint_refreshes_on_retransmission` passes
|
||||
without this patch.
|
||||
|
||||
verification:
|
||||
|
||||
```sh
|
||||
zig build test-lib-vt -Dtest-filter="image_get transmit_time_ns changes on retransmission"
|
||||
cargo nextest run kitty_image_fingerprint
|
||||
```
|
||||
|
|
|
|||
|
|
@ -343,6 +343,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8,
|
||||
|
||||
/**
|
||||
* Timestamp of the image's most recent transmission, in nanoseconds.
|
||||
* The epoch is unspecified; only equality and ordering between values
|
||||
* read from the same terminal are meaningful.
|
||||
*
|
||||
* Output type: uint64_t *
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9,
|
||||
|
||||
GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyKittyGraphicsImageData;
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ pub const ImageData = enum(c_int) {
|
|||
compression = 6,
|
||||
data_ptr = 7,
|
||||
data_len = 8,
|
||||
transmit_time_ns = 9,
|
||||
|
||||
pub fn OutType(comptime self: ImageData) type {
|
||||
return switch (self) {
|
||||
|
|
@ -187,6 +188,7 @@ pub const ImageData = enum(c_int) {
|
|||
.compression => ImageCompression,
|
||||
.data_ptr => [*]const u8,
|
||||
.data_len => usize,
|
||||
.transmit_time_ns => u64,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -258,11 +260,22 @@ fn imageGetTyped(
|
|||
.compression => out.* = image.compression,
|
||||
.data_ptr => out.* = image.data.ptr,
|
||||
.data_len => out.* = image.data.len,
|
||||
.transmit_time_ns => out.* = instantNanos(image.transmit_time),
|
||||
}
|
||||
|
||||
return .success;
|
||||
}
|
||||
|
||||
/// Flattens an Instant's platform timestamp to nanoseconds for the C API.
|
||||
/// The epoch is unspecified; only equality and ordering are meaningful.
|
||||
fn instantNanos(instant: std.time.Instant) u64 {
|
||||
if (@TypeOf(instant.timestamp) == u64) return instant.timestamp;
|
||||
const ts = instant.timestamp;
|
||||
const sec: u64 = @intCast(@max(ts.sec, 0));
|
||||
const nsec: u64 = @intCast(@max(ts.nsec, 0));
|
||||
return sec *| std.time.ns_per_s +| nsec;
|
||||
}
|
||||
|
||||
pub fn placement_iterator_new(
|
||||
alloc_: ?*const CAllocator,
|
||||
out: *PlacementIterator,
|
||||
|
|
@ -977,6 +990,48 @@ test "image_get_handle and image_get with transmitted image" {
|
|||
try testing.expect(data_len > 0);
|
||||
}
|
||||
|
||||
test "image_get transmit_time_ns changes on retransmission" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
var t: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
));
|
||||
defer terminal_c.free(t);
|
||||
|
||||
const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
|
||||
terminal_c.vt_write(t, cmd.ptr, cmd.len);
|
||||
|
||||
var graphics: KittyGraphics = undefined;
|
||||
try testing.expectEqual(Result.success, terminal_c.get(
|
||||
t,
|
||||
.kitty_graphics,
|
||||
@ptrCast(&graphics),
|
||||
));
|
||||
|
||||
var first: u64 = undefined;
|
||||
try testing.expectEqual(Result.success, image_get(
|
||||
image_get_handle(graphics, 1),
|
||||
.transmit_time_ns,
|
||||
@ptrCast(&first),
|
||||
));
|
||||
try testing.expect(first > 0);
|
||||
|
||||
std.Thread.sleep(1 * std.time.ns_per_ms);
|
||||
const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
|
||||
terminal_c.vt_write(t, retransmit.ptr, retransmit.len);
|
||||
|
||||
var second: u64 = undefined;
|
||||
try testing.expectEqual(Result.success, image_get(
|
||||
image_get_handle(graphics, 1),
|
||||
.transmit_time_ns,
|
||||
@ptrCast(&second),
|
||||
));
|
||||
try testing.expect(second > first);
|
||||
}
|
||||
|
||||
test "placement_rect with transmit and display" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
|
|
|
|||
112
vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch
vendored
Normal file
112
vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h
|
||||
index 9bace3a..d5b5006 100644
|
||||
--- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h
|
||||
+++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h
|
||||
@@ -343,6 +343,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
*/
|
||||
GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8,
|
||||
|
||||
+ /**
|
||||
+ * Timestamp of the image's most recent transmission, in nanoseconds.
|
||||
+ * The epoch is unspecified; only equality and ordering between values
|
||||
+ * read from the same terminal are meaningful.
|
||||
+ *
|
||||
+ * Output type: uint64_t *
|
||||
+ */
|
||||
+ GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9,
|
||||
+
|
||||
GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyKittyGraphicsImageData;
|
||||
|
||||
diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
|
||||
index d50b8c4..a00632e 100644
|
||||
--- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
|
||||
+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
|
||||
@@ -178,6 +178,7 @@ pub const ImageData = enum(c_int) {
|
||||
compression = 6,
|
||||
data_ptr = 7,
|
||||
data_len = 8,
|
||||
+ transmit_time_ns = 9,
|
||||
|
||||
pub fn OutType(comptime self: ImageData) type {
|
||||
return switch (self) {
|
||||
@@ -187,6 +188,7 @@ pub const ImageData = enum(c_int) {
|
||||
.compression => ImageCompression,
|
||||
.data_ptr => [*]const u8,
|
||||
.data_len => usize,
|
||||
+ .transmit_time_ns => u64,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -258,11 +260,22 @@ fn imageGetTyped(
|
||||
.compression => out.* = image.compression,
|
||||
.data_ptr => out.* = image.data.ptr,
|
||||
.data_len => out.* = image.data.len,
|
||||
+ .transmit_time_ns => out.* = instantNanos(image.transmit_time),
|
||||
}
|
||||
|
||||
return .success;
|
||||
}
|
||||
|
||||
+/// Flattens an Instant's platform timestamp to nanoseconds for the C API.
|
||||
+/// The epoch is unspecified; only equality and ordering are meaningful.
|
||||
+fn instantNanos(instant: std.time.Instant) u64 {
|
||||
+ if (@TypeOf(instant.timestamp) == u64) return instant.timestamp;
|
||||
+ const ts = instant.timestamp;
|
||||
+ const sec: u64 = @intCast(@max(ts.sec, 0));
|
||||
+ const nsec: u64 = @intCast(@max(ts.nsec, 0));
|
||||
+ return sec *| std.time.ns_per_s +| nsec;
|
||||
+}
|
||||
+
|
||||
pub fn placement_iterator_new(
|
||||
alloc_: ?*const CAllocator,
|
||||
out: *PlacementIterator,
|
||||
@@ -977,6 +990,48 @@ test "image_get_handle and image_get with transmitted image" {
|
||||
try testing.expect(data_len > 0);
|
||||
}
|
||||
|
||||
+test "image_get transmit_time_ns changes on retransmission" {
|
||||
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
+
|
||||
+ var t: terminal_c.Terminal = null;
|
||||
+ try testing.expectEqual(Result.success, terminal_c.new(
|
||||
+ &lib.alloc.test_allocator,
|
||||
+ &t,
|
||||
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
|
||||
+ ));
|
||||
+ defer terminal_c.free(t);
|
||||
+
|
||||
+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
|
||||
+ terminal_c.vt_write(t, cmd.ptr, cmd.len);
|
||||
+
|
||||
+ var graphics: KittyGraphics = undefined;
|
||||
+ try testing.expectEqual(Result.success, terminal_c.get(
|
||||
+ t,
|
||||
+ .kitty_graphics,
|
||||
+ @ptrCast(&graphics),
|
||||
+ ));
|
||||
+
|
||||
+ var first: u64 = undefined;
|
||||
+ try testing.expectEqual(Result.success, image_get(
|
||||
+ image_get_handle(graphics, 1),
|
||||
+ .transmit_time_ns,
|
||||
+ @ptrCast(&first),
|
||||
+ ));
|
||||
+ try testing.expect(first > 0);
|
||||
+
|
||||
+ std.Thread.sleep(1 * std.time.ns_per_ms);
|
||||
+ const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
|
||||
+ terminal_c.vt_write(t, retransmit.ptr, retransmit.len);
|
||||
+
|
||||
+ var second: u64 = undefined;
|
||||
+ try testing.expectEqual(Result.success, image_get(
|
||||
+ image_get_handle(graphics, 1),
|
||||
+ .transmit_time_ns,
|
||||
+ @ptrCast(&second),
|
||||
+ ));
|
||||
+ try testing.expect(second > first);
|
||||
+}
|
||||
+
|
||||
test "placement_rect with transmit and display" {
|
||||
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
|
||||
|
||||
Loading…
Reference in New Issue