diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index 2c3b5a07..73914a98 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -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; diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index e79c9a16..97b22f59 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -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, Error> { pub struct Terminal { raw: ffi::GhosttyTerminal_ptr, write_pty_callback: Option>, + kitty_fingerprints: Mutex>, } 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 = placements + .iter() + .map(|placement| placement.image_id) + .collect(); + cache.retain(|image_id, _| live.contains(image_id)); + } + } + fn kitty_image_placement( &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 { + 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 { 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 { 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, 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] diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 4564bde6..2e83841c 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -120,6 +120,8 @@ struct ClippedPlacement { pub(crate) struct HostGraphicsCache { images: HashMap, placements: HashMap<(u32, u32), PlacementSignature>, + /// Host image currently backing each (pane, source image id) pair. + sources: HashMap<(PaneId, u32), u32>, view: Option, } @@ -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, 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, + sources: &mut HashMap<(PaneId, u32), u32>, + host_images: &mut HashMap, + 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")); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 98888f6a..02e53352 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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); diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 98b7569e..94bb998b 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -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 +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index 9bace3a3..d5b50067 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 d50b8c4c..a00632ed 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; diff --git a/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch b/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch new file mode 100644 index 00000000..4289740c --- /dev/null +++ b/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch @@ -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; +