aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 03:52:42 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 03:53:37 +0300
commit7c636e97f4568583651de3bc8c8550a3bab54743 (patch)
treed189e60904307bdfe8d3cfc89428fd3e43f88a90
parent67d5e7391f70cc7ecfe99eaf23132d12152340de (diff)
fix: refresh and reuse kitty graphics placements
-rw-r--r--src/ghostty/mod.rs45
-rw-r--r--src/kitty_graphics.rs120
2 files changed, 154 insertions, 11 deletions
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index b69dfdb7..a0ff3226 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -148,6 +148,10 @@ pub const MODE_SYNCHRONIZED_OUTPUT: u16 = 2026;
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;
static INSTALL_PNG_DECODER: Once = Once::new();
@@ -1188,10 +1192,23 @@ fn kitty_image_fingerprint(
}
let data = unsafe { slice::from_raw_parts(ptr_out, len) };
- data.hash(&mut hasher);
+ hash_kitty_image_data_sample(&mut hasher, data);
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 =
@@ -1863,6 +1880,32 @@ mod tests {
}
#[test]
+ fn kitty_image_fingerprint_samples_large_payloads() {
+ let mut data = vec![1u8; KITTY_FINGERPRINT_SAMPLE_BYTES * 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 =
+ kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
+ assert_ne!(original, changed_prefix);
+
+ 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);
+
+ 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);
+ }
+
+ #[test]
fn build_info_contract_matches_expected_vendored_features() {
let _simd = build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_SIMD);
let _tmux_control_mode =
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 329e67d6..de1ac58a 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -98,6 +98,7 @@ struct PlacementSignature {
x_offset: u32,
y_offset: u32,
z: i32,
+ scrollback_offset: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -210,7 +211,7 @@ pub(crate) fn encode_local_pane_graphics(
fn encode_graphics_update(
bytes: &mut Vec<u8>,
placements: &[HostPlacement],
- _view_changed: bool,
+ view_changed: bool,
host_images: &mut HashMap<u32, ImageSignature>,
host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
) {
@@ -234,7 +235,8 @@ fn encode_graphics_update(
let host_id = host_image_id(placement.pane_id, &placement.placement);
let host_placement_id = host_placement_id(placement.pane_id, &placement.placement);
let image_signature = image_signature(placement, format_code);
- let placement_signature = placement_signature(clipped, placement.placement.z);
+ let placement_signature =
+ placement_signature(clipped, placement.placement.z, placement.scrollback_offset);
let placement_key = (host_id, host_placement_id);
current_placements.insert(placement_key);
@@ -263,8 +265,11 @@ fn encode_graphics_update(
}
}
+ // 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.
match host_placements.get_mut(&placement_key) {
- Some(existing) if *existing == placement_signature => {}
+ Some(existing) if !view_changed && *existing == placement_signature => {}
Some(existing) => {
encode_display_placement(
bytes,
@@ -385,10 +390,10 @@ fn collect_visible_placements(
}
};
for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| {
- let host_id = host_image_id_raw(info.id, descriptor.image_id);
let format_code = kitty_format_code(descriptor.format);
- uploaded_images.get(&host_id).copied()
- != Some(image_signature_from_descriptor(descriptor, format_code))
+ let signature = image_signature_from_descriptor(descriptor, format_code);
+ let host_id = host_image_id_for_signature(info.id, signature);
+ uploaded_images.get(&host_id).copied() != Some(signature)
}) {
let scrollback_offset = runtime
.scroll_metrics()
@@ -411,13 +416,23 @@ fn collect_visible_placements(
}
fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 {
- host_image_id_raw(pane_id, placement.image_id)
+ let format_code = kitty_format_code(placement.format);
+ host_image_id_for_signature(
+ pane_id,
+ ImageSignature {
+ image_width: placement.image_width,
+ image_height: placement.image_height,
+ format_code,
+ data_len: placement.data_len,
+ data_fingerprint: placement.data_fingerprint,
+ },
+ )
}
-fn host_image_id_raw(pane_id: PaneId, image_id: u32) -> u32 {
+fn host_image_id_for_signature(pane_id: PaneId, signature: ImageSignature) -> u32 {
let mut hasher = DefaultHasher::new();
pane_id.raw().hash(&mut hasher);
- image_id.hash(&mut hasher);
+ signature.hash(&mut hasher);
HOST_IMAGE_ID_BASE + ((hasher.finish() as u32) % 900_000)
}
@@ -663,7 +678,11 @@ fn image_signature_from_descriptor(
}
}
-fn placement_signature(clipped: ClippedPlacement, z: i32) -> PlacementSignature {
+fn placement_signature(
+ clipped: ClippedPlacement,
+ z: i32,
+ scrollback_offset: u32,
+) -> PlacementSignature {
PlacementSignature {
x: clipped.x,
y: clipped.y,
@@ -676,6 +695,7 @@ fn placement_signature(clipped: ClippedPlacement, z: i32) -> PlacementSignature
x_offset: clipped.x_offset,
y_offset: clipped.y_offset,
z,
+ scrollback_offset,
}
}
@@ -816,6 +836,55 @@ mod tests {
}
#[test]
+ fn view_change_redisplays_unchanged_visible_placement() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let placement = test_placement(0, 0);
+
+ encode_graphics_update(
+ &mut bytes,
+ &[placement],
+ false,
+ &mut images,
+ &mut placements,
+ );
+ assert_eq!(placements.len(), 1);
+
+ bytes.clear();
+ let same = test_placement(0, 0);
+ encode_graphics_update(&mut bytes, &[same], true, &mut images, &mut placements);
+ let redisplay = String::from_utf8_lossy(&bytes);
+ assert!(!redisplay.contains("a=t"));
+ assert!(redisplay.contains("a=p"));
+ assert_eq!(placements.len(), 1);
+ }
+
+ #[test]
+ fn scrollback_offset_change_redisplays_placement() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let placement = test_placement(0, 0);
+
+ encode_graphics_update(
+ &mut bytes,
+ &[placement],
+ false,
+ &mut images,
+ &mut placements,
+ );
+
+ bytes.clear();
+ let mut scrolled = test_placement(0, 0);
+ scrolled.scrollback_offset = 3;
+ encode_graphics_update(&mut bytes, &[scrolled], false, &mut images, &mut placements);
+ let redisplay = String::from_utf8_lossy(&bytes);
+ assert!(!redisplay.contains("a=t"));
+ assert!(redisplay.contains("a=p"));
+ }
+
+ #[test]
fn empty_image_data_does_not_mark_image_uploaded() {
let mut images = HashMap::new();
let mut placements = HashMap::new();
@@ -837,6 +906,37 @@ mod tests {
}
#[test]
+ fn same_image_signature_reuses_host_upload_across_source_image_ids() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let first = test_placement(0, 0);
+
+ encode_graphics_update(&mut bytes, &[first], false, &mut images, &mut placements);
+ assert_eq!(images.len(), 1);
+ assert_eq!(placements.len(), 1);
+
+ bytes.clear();
+ let mut same_image_new_source_id = test_placement(0, 0);
+ same_image_new_source_id.placement.image_id = 8;
+ same_image_new_source_id.placement.placement_id = 4;
+ same_image_new_source_id.placement.data.clear();
+ encode_graphics_update(
+ &mut bytes,
+ &[same_image_new_source_id],
+ false,
+ &mut images,
+ &mut placements,
+ );
+
+ let reused = String::from_utf8_lossy(&bytes);
+ assert!(!reused.contains("a=t"));
+ assert!(reused.contains("a=p"));
+ assert_eq!(images.len(), 1);
+ assert_eq!(placements.len(), 1);
+ }
+
+ #[test]
fn stale_placement_deletes_placement_not_image_immediately() {
let mut images = HashMap::new();
let mut placements = HashMap::new();