aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ghostty/bindings.rs2
-rw-r--r--src/ghostty/mod.rs171
-rw-r--r--src/kitty_graphics.rs269
-rw-r--r--src/pane/terminal.rs21
4 files changed, 414 insertions, 49 deletions
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<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]
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<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);
@@ -1041,9 +1142,146 @@ mod tests {
}
#[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,
@@ -3514,6 +3514,25 @@ mod tests {
}
#[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);
let terminal = crate::ghostty::Terminal::new(20, 5, 100).unwrap();