diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-24 20:59:48 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-24 20:59:48 +0300 |
| commit | c411883ec639c9893ed9c33021c890485e91727b (patch) | |
| tree | 797bb6efdb3a9425e3d8c31953f62452bd564cb1 /src/ghostty | |
| parent | 11a7ab4ae21ab9427bcb44b3be01f62a4ab58c47 (diff) | |
feat!: replace the custom graphics api with optimized native kitty rendering (#4561)
* feat!: replace the custom graphics api with optimized native kitty rendering
* fix: enable safe kitty optimizations automatically
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/ghostty')
| -rw-r--r-- | src/ghostty/bindings.rs | 96 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 241 | ||||
| -rw-r--r-- | src/ghostty/native_source.rs | 165 | ||||
| -rw-r--r-- | src/ghostty/native_source_tests.rs | 146 | ||||
| -rw-r--r-- | src/ghostty/png_forward_tests.rs | 232 |
5 files changed, 845 insertions, 35 deletions
diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index ceb2d699..33443aca 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -2069,16 +2069,28 @@ pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT: #[doc = " Compression of the image. Always\n GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are\n inflated before storage.\n\n Output type: GhosttyKittyImageCompression *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION: GhosttyKittyGraphicsImageData = 6; -#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload this\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"] +#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending, or\n when the experimental PRESERVE_PNG option retained encoded PNG data.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are normally decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload returned pixels\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR: GhosttyKittyGraphicsImageData = 7; -#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending image, this is the expected length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"] +#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending or encoded PNG image, this is the expected decoded length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN: GhosttyKittyGraphicsImageData = 8; #[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION: GhosttyKittyGraphicsImageData = 9; -#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"] +#[doc = " Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE\n unless experimentally retained. Output type: const uint8_t **."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR: + GhosttyKittyGraphicsImageData = 10; +#[doc = " Encoded PNG length; NO_VALUE unless retained. Output type: size_t *."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN: + GhosttyKittyGraphicsImageData = 11; +#[doc = " Borrowed void* backing context; valid only while image is unchanged."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT: + GhosttyKittyGraphicsImageData = 12; +#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY: + GhosttyKittyGraphicsImageData = 13; +#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE: GhosttyKittyGraphicsImageData = 2147483647; #[doc = " Queryable data kinds for ghostty_kitty_graphics_image_get().\n\n @ingroup kitty_graphics"] @@ -2282,6 +2294,76 @@ unsafe extern "C" { out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo, ) -> GhosttyResult; } +#[doc = " Borrowed opened descriptor; size is sizeof this request. No path is supplied."] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyKittyImageSnapshotFileRequest { + pub size: usize, + pub fd: i64, + pub expected_len: u64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyKittyImageSnapshotFileRequest"] + [::std::mem::size_of::<GhosttyKittyImageSnapshotFileRequest>() - 24usize]; + ["Alignment of GhosttyKittyImageSnapshotFileRequest"] + [::std::mem::align_of::<GhosttyKittyImageSnapshotFileRequest>() - 8usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::size"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, size) - 0usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::fd"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, fd) - 8usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::expected_len"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, expected_len) - 16usize]; +}; +pub type GhosttyKittyImageFileReadFn = ::std::option::Option< + unsafe extern "C" fn(context: *mut ::std::os::raw::c_void, dest: *mut u8, len: usize) -> bool, +>; +pub type GhosttyKittyImageFileReleaseFn = + ::std::option::Option<unsafe extern "C" fn(context: *mut ::std::os::raw::c_void)>; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyImageFileBacking { + pub context: *mut ::std::os::raw::c_void, + pub identity: u64, + pub len: usize, + pub read: GhosttyKittyImageFileReadFn, + pub release: GhosttyKittyImageFileReleaseFn, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyKittyImageFileBacking"] + [::std::mem::size_of::<GhosttyKittyImageFileBacking>() - 40usize]; + ["Alignment of GhosttyKittyImageFileBacking"] + [::std::mem::align_of::<GhosttyKittyImageFileBacking>() - 8usize]; + ["Offset of field: GhosttyKittyImageFileBacking::context"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, context) - 0usize]; + ["Offset of field: GhosttyKittyImageFileBacking::identity"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, identity) - 8usize]; + ["Offset of field: GhosttyKittyImageFileBacking::len"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, len) - 16usize]; + ["Offset of field: GhosttyKittyImageFileBacking::read"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, read) - 24usize]; + ["Offset of field: GhosttyKittyImageFileBacking::release"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, release) - 32usize]; +}; +impl Default for GhosttyKittyImageFileBacking { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Optional synchronous callback, default NULL. Verify exact descriptor size\n and snapshot immutable bytes before returning true. True transfers one context\n reference; false transfers nothing. Do not close the borrowed descriptor or\n reenter the terminal. Read must fill exactly len bytes; release is called once.\n identity identifies the immutable revision, not a pathname or source inode."] +pub type GhosttyKittyImageSnapshotFileFn = ::std::option::Option< + unsafe extern "C" fn( + terminal: GhosttyTerminal, + userdata: *mut ::std::os::raw::c_void, + request: *const GhosttyKittyImageSnapshotFileRequest, + out_backing: *mut GhosttyKittyImageFileBacking, + ) -> bool, +>; #[doc = " Perform one bounded compression step suitable for idle scheduling."] pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL: GhosttyTerminalCompressionMode = 0; @@ -3119,7 +3201,13 @@ pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ: GhosttyTerm #[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"] pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES: GhosttyTerminalOption = 39; -#[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"] +#[doc = " Experimental, default-off PNG retention for quiet base transmissions.\n Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only\n be detected when an animation operation materializes the image."] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG: + GhosttyTerminalOption = 40; +#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE: + GhosttyTerminalOption = 41; +#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."] pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647; #[doc = " Terminal option identifiers.\n\n These values are used with ghostty_terminal_set() to configure\n terminal callbacks and associated state.\n\n @ingroup terminal"] pub type GhosttyTerminalOption = ::std::os::raw::c_int; diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 82714118..43a70fb1 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -22,7 +22,10 @@ use std::ops::RangeInclusive; use std::os::raw::c_char; use std::ptr; use std::slice; -use std::sync::{Mutex, Once, OnceLock}; +use std::sync::{Arc, Mutex, Once, OnceLock}; + +use crate::pane_graphics_files::OwnedExport; +mod native_source; pub use bindings as ffi; @@ -227,6 +230,7 @@ pub struct KittyImagePlacement { pub data_len: usize, pub data_fingerprint: u64, pub data: Vec<u8>, + pub(crate) source_file: Option<Arc<OwnedExport>>, pub render: KittyPlacementRenderInfo, } @@ -239,8 +243,11 @@ pub struct KittyImageDescriptor { pub format: KittyImageFormat, pub data_len: usize, pub data_fingerprint: u64, + pub source_file: bool, } +type KittyPlacementPayload = (KittyImageDescriptor, Vec<u8>, Option<Arc<OwnedExport>>); + #[derive(Debug, Clone, Copy)] struct KittyImageFingerprintEntry { generation: u64, @@ -690,6 +697,16 @@ fn install_png_decoder_once() { }); } +#[cfg(test)] +thread_local! { + static PNG_DECODE_CALLS: Cell<usize> = const { Cell::new(0) }; +} + +#[cfg(test)] +mod native_source_tests; +#[cfg(test)] +mod png_forward_tests; + unsafe extern "C" fn decode_png_trampoline( _userdata: *mut c_void, allocator: *const ffi::GhosttyAllocator, @@ -701,6 +718,8 @@ unsafe extern "C" fn decode_png_trampoline( return false; } let bytes = unsafe { slice::from_raw_parts(data, data_len) }; + #[cfg(test)] + PNG_DECODE_CALLS.with(|calls| calls.set(calls.get() + 1)); let Some(rgba) = decode_png_rgba(bytes) else { return false; }; @@ -820,6 +839,7 @@ pub struct Terminal { callback_state: Box<TerminalCallbackState>, kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>, kitty_empty_generation: Cell<Option<u64>>, + kitty_png_forwarding: bool, } impl Terminal { @@ -845,6 +865,7 @@ impl Terminal { }), kitty_fingerprints: Mutex::new(HashMap::new()), kitty_empty_generation: Cell::new(None), + kitty_png_forwarding: false, }; let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(); let glyph_protocol = false; @@ -1072,6 +1093,25 @@ impl Terminal { ) .into_result()?; } + self.set_kitty_source_forwarding(true)?; + Ok(()) + } + + pub fn set_kitty_source_forwarding(&mut self, enabled: bool) -> Result<(), Error> { + native_source::set_forwarding(self.raw, enabled) + } + + #[cfg(test)] + fn set_kitty_png_forwarding(&mut self, enabled: bool) -> Result<(), Error> { + unsafe { + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG, + (&enabled as *const bool).cast(), + ) + .into_result()?; + } + self.kitty_png_forwarding = enabled; Ok(()) } @@ -1882,6 +1922,65 @@ impl Terminal { } } + // Retained files are authoritative: never ask the native lazy decoder for + // pixels just to describe a placement or calculate its cache identity. + fn kitty_placement_payload<F>( + &self, + image: ffi::GhosttyKittyGraphicsImage, + image_id: u32, + placement_id: u32, + image_width: u32, + image_height: u32, + decoded_format: KittyImageFormat, + needs_data: &mut F, + ) -> Result<KittyPlacementPayload, Error> + where + F: FnMut(KittyImageDescriptor) -> bool, + { + let source_file = native_source::image_source(image)?; + let (format, data_ptr, data_len, data_fingerprint) = if let Some(source) = &source_file { + ( + KittyImageFormat::Rgba, + ptr::null(), + source.len(), + source.fingerprint(), + ) + } else { + let (format, data_ptr, data_len) = + kitty_image_transmission_payload(image, decoded_format, self.kitty_png_forwarding)?; + let fingerprint = self.kitty_image_fingerprint_cached( + image, + image_id, + (data_ptr, data_len), + image_width, + image_height, + format, + ); + (format, data_ptr, data_len, fingerprint) + }; + let descriptor = KittyImageDescriptor { + image_id, + placement_id, + image_width, + image_height, + format, + data_len, + data_fingerprint, + source_file: source_file.is_some(), + }; + let data = if needs_data(descriptor) { + match &source_file { + Some(source) => source + .copy_rgba() + .map_err(|_| Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE))?, + None => kitty_image_data_from_ptr(data_ptr, data_len), + } + } else { + Vec::new() + }; + Ok((descriptor, data, source_file)) + } + fn kitty_image_placement<F>( &self, graphics: ffi::GhosttyKittyGraphics, @@ -1937,29 +2036,21 @@ impl Terminal { iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, )?; - let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?; - let data_fingerprint = self.kitty_image_fingerprint_cached( + let (descriptor, data, source_file) = self.kitty_placement_payload( image, image_id, - (data_ptr, data_len), - image_width, - image_height, - format, - ); - let descriptor = KittyImageDescriptor { - image_id, placement_id, image_width, image_height, format, + needs_data, + )?; + let KittyImageDescriptor { + format, data_len, data_fingerprint, - }; - let data = if needs_data(descriptor) { - kitty_image_data_from_ptr(data_ptr, data_len) - } else { - Vec::new() - }; + .. + } = descriptor; let x_offset = kitty_placement_u32( iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, @@ -1985,6 +2076,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: KittyPlacementRenderInfo { pixel_width: raw_info.pixel_width, pixel_height: raw_info.pixel_height, @@ -2081,29 +2173,21 @@ impl Terminal { continue; }; let placement_id = run.synthetic_placement_id(); - let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?; - let data_fingerprint = self.kitty_image_fingerprint_cached( + let (descriptor, data, source_file) = self.kitty_placement_payload( image, image_id, - (data_ptr, data_len), - image_width, - image_height, - format, - ); - let descriptor = KittyImageDescriptor { - image_id, placement_id, image_width, image_height, format, + needs_data, + )?; + let KittyImageDescriptor { + format, data_len, data_fingerprint, - }; - let data = if needs_data(descriptor) { - kitty_image_data_from_ptr(data_ptr, data_len) - } else { - Vec::new() - }; + .. + } = descriptor; placements.push(KittyImagePlacement { image_id, placement_id, @@ -2116,6 +2200,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: geometry.render, }); } @@ -2585,6 +2670,40 @@ fn kitty_image_compression( Ok(out) } +fn kitty_image_transmission_payload( + image: ffi::GhosttyKittyGraphicsImage, + decoded_format: KittyImageFormat, + forwarding: bool, +) -> Result<(KittyImageFormat, *const u8, usize), Error> { + if !forwarding { + let (ptr, len) = kitty_image_data_ptr_len(image)?; + return Ok((decoded_format, ptr, len)); + } + let mut ptr_out: *const u8 = ptr::null(); + let result = unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR, + (&mut ptr_out as *mut *const u8).cast(), + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + let (ptr, len) = kitty_image_data_ptr_len(image)?; + return Ok((decoded_format, ptr, len)); + } + result.into_result()?; + let mut len = 0usize; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN, + (&mut len as *mut usize).cast(), + ) + .into_result()?; + } + Ok((KittyImageFormat::Png, ptr_out, len)) +} + fn kitty_image_data_ptr_len( image: ffi::GhosttyKittyGraphicsImage, ) -> Result<(*const u8, usize), Error> { @@ -3593,6 +3712,66 @@ mod tests { } #[test] + fn kitty_png_replacement_rejects_invalid_payload_without_placing_it() { + use base64::Engine as _; + let mut png = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 255]) + .unwrap(); + } + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + let upload = |payload: &[u8]| { + format!( + "\x1b[H\x1b_Ga=T,f=100,i=7,p=3,c=2,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(payload) + ) + }; + terminal.write(upload(&png).as_bytes()); + let before = terminal.kitty_image_placements().unwrap(); + assert_eq!(before.len(), 1); + assert_eq!((before[0].image_width, before[0].image_height), (2, 1)); + assert_eq!(before[0].data, [255, 0, 0, 255, 0, 0, 255, 255]); + // The parser retires the old placement, but never places corrupt data. + terminal.write(upload(&png[..png.len() / 2]).as_bytes()); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn kitty_file_image_survives_source_mutation_and_unlink() { + use base64::Engine as _; + let path = std::path::PathBuf::from(format!( + "/var/tmp/herdr-kitty-source-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, [255, 0, 0, 255]).unwrap(); + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.write( + format!( + "\x1b_Ga=T,f=32,t=f,i=7,p=3,s=1,v=1,c=1,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap()) + ) + .as_bytes(), + ); + std::fs::write(&path, [0, 0, 255, 255]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].data, [255, 0, 0, 255]); + } + + #[test] fn kitty_image_fingerprint_covers_full_payload() { let mut data = vec![1u8; 4096 * 4]; let original = diff --git a/src/ghostty/native_source.rs b/src/ghostty/native_source.rs new file mode 100644 index 00000000..3e4e8a3f --- /dev/null +++ b/src/ghostty/native_source.rs @@ -0,0 +1,165 @@ +//! Host-owned immutable file snapshots retained by the native image lifetime. +use super::{ffi, Error, GhosttyResultExt}; +use crate::pane_graphics_files::{FileStore, OwnedExport}; +use std::ffi::c_void; +use std::ptr; +use std::sync::{Arc, OnceLock}; + +static SOURCES: OnceLock<FileStore> = OnceLock::new(); + +// These getters never materialize native pixels. The image borrow protects the +// host's strong reference until we have acquired our own. +pub(super) fn image_source( + image: ffi::GhosttyKittyGraphicsImage, +) -> Result<Option<Arc<OwnedExport>>, Error> { + let mut context: *mut c_void = ptr::null_mut(); + let result = unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT, + (&mut context as *mut *mut c_void).cast(), + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(None); + } + result.into_result()?; + if context.is_null() { + return Ok(None); + } + let mut identity = 0u64; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY, + (&mut identity as *mut u64).cast(), + ) + .into_result()?; + let source = context.cast::<OwnedExport>(); + // All terminal snapshot callbacks in this wrapper produce this type. + // Identity is immutable and does not depend on decoded pixel storage. + if (*source).fingerprint() != identity { + return Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE)); + } + Arc::increment_strong_count(source); + Ok(Some(Arc::from_raw(source))) + } +} + +pub(super) fn set_forwarding(terminal: ffi::GhosttyTerminal, enabled: bool) -> Result<(), Error> { + let callback = if enabled && cfg!(target_os = "linux") { + snapshot as *const () as *const c_void + } else { + ptr::null() + }; + unsafe { + ffi::ghostty_terminal_set( + terminal, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE, + callback, + ) + .into_result() + } +} + +unsafe extern "C" fn snapshot( + _terminal: ffi::GhosttyTerminal, + _userdata: *mut c_void, + request: *const ffi::GhosttyKittyImageSnapshotFileRequest, + out: *mut ffi::GhosttyKittyImageFileBacking, +) -> bool { + std::panic::catch_unwind(|| { + if request.is_null() || out.is_null() || !cfg!(target_os = "linux") { + return false; + } + let request = unsafe { &*request }; + if request.size != std::mem::size_of::<ffi::GhosttyKittyImageSnapshotFileRequest>() { + return false; + } + let Ok(len) = usize::try_from(request.expected_len) else { + return false; + }; + let Ok(source) = SOURCES + .get_or_init(FileStore::native_sources) + .snapshot(request.fd, len) + else { + return false; + }; + let source = Arc::new(source); + let backing = ffi::GhosttyKittyImageFileBacking { + identity: source.fingerprint(), + len: source.len(), + context: Arc::into_raw(source).cast_mut().cast(), + read: Some(read), + release: Some(release), + }; + unsafe { + out.write(backing); + } + true + }) + .unwrap_or(false) +} + +unsafe extern "C" fn read(context: *mut c_void, dest: *mut u8, len: usize) -> bool { + std::panic::catch_unwind(|| { + if context.is_null() || dest.is_null() { + return false; + } + let source = unsafe { &*context.cast::<OwnedExport>() }; + if len != source.len() || len > isize::MAX as usize { + return false; + } + source + .read_into(unsafe { std::slice::from_raw_parts_mut(dest, len) }) + .is_ok() + }) + .unwrap_or(false) +} + +unsafe extern "C" fn release(context: *mut c_void) { + let _ = std::panic::catch_unwind(|| { + if !context.is_null() { + drop(unsafe { Arc::from_raw(context.cast::<OwnedExport>()) }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_requests_decline_without_transferring_ownership() { + let mut out = ffi::GhosttyKittyImageFileBacking::default(); + let mut request = ffi::GhosttyKittyImageSnapshotFileRequest { + size: std::mem::size_of::<ffi::GhosttyKittyImageSnapshotFileRequest>(), + fd: -1, + expected_len: 4, + }; + unsafe { + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + &request, + &mut out + )); + assert!(out.context.is_null()); + request.size = 0; + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + &request, + &mut out + )); + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + &mut out + )); + assert!(out.context.is_null()); + release(ptr::null_mut()); + } + } +} diff --git a/src/ghostty/native_source_tests.rs b/src/ghostty/native_source_tests.rs new file mode 100644 index 00000000..bb4ded46 --- /dev/null +++ b/src/ghostty/native_source_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use base64::Engine as _; + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + terminal +} + +#[test] +fn decoded_upload_remains_bytes_backed() { + let mut terminal = terminal(); + let bytes = [17u8; 16]; + terminal.write( + format!( + "\x1b_Ga=T,f=32,s=2,v=2,i=7,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + .as_bytes(), + ); + let metadata = terminal + .kitty_image_placements_with_data_filter(|descriptor| { + assert!(!descriptor.source_file); + assert_eq!(descriptor.data_len, bytes.len()); + false + }) + .unwrap(); + assert_eq!(metadata.len(), 1); + assert!(metadata[0].data.is_empty()); + assert!(metadata[0].source_file.is_none()); + let compatible = terminal.kitty_image_placements().unwrap(); + assert_eq!(compatible[0].data, bytes); + assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint); +} + +#[cfg(unix)] +#[test] +fn regular_file_placement_survives_alternate_screen_replacement() { + let store = crate::pane_graphics_files::FileStore::default(); + let mut terminal = terminal(); + terminal.set_kitty_source_forwarding(false).unwrap(); + terminal.write(b"\x1b[?1049h\x1b[2J\x1b[H"); + for value in [17u8, 99] { + let bytes = [value; 16]; + let source = store.export(&bytes).unwrap(); + terminal.write( + format!( + "\x1b[H\x1b_Ga=T,t=f,f=32,s=2,v=2,i=77,p=1,c=2,r=1,C=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD + .encode(source.path().as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].data, bytes); + assert_eq!(placements[0].render.viewport_row, 0); + assert_eq!(placements[0].render.grid_rows, 1); + assert_eq!(placements[0].render.grid_cols, 2); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn file_upload_automatically_retains_snapshot_when_platform_supports_clone() { + use std::os::fd::AsRawFd; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let base = std::env::var_os("TMPDIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| "/var/tmp".into()); + let path = base.join(format!( + "herdr-native-source-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let bytes = [42u8; 16]; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + use std::io::Write; + (&file).write_all(&bytes).unwrap(); + // Probe the actual platform, not filesystem names: overlayfs/tmpfs must + // decline rather than silently produce a userspace copy. + let probe = crate::pane_graphics_files::FileStore::native_sources() + .snapshot(i64::from(file.as_raw_fd()), bytes.len()); + let supported = match probe { + Ok(snapshot) => { + drop(snapshot); + true + } + Err(error) => { + eprintln!("native snapshot unsupported on test filesystem: {error}"); + false + } + }; + let mut terminal = terminal(); + terminal.write( + format!( + "\x1b_Ga=T,t=f,f=32,s=2,v=2,i=7,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + let metadata = terminal + .kitty_image_placements_with_data_filter(|descriptor| { + assert_eq!(descriptor.source_file, supported); + false + }) + .unwrap(); + assert_eq!(metadata.len(), 1); + assert!(metadata[0].data.is_empty()); + assert_eq!(metadata[0].source_file.is_some(), supported); + let mut unicode = self::terminal(); + unicode.write( + format!( + "\x1b_Ga=T,t=f,f=32,s=2,v=2,U=1,c=1,r=1,i=8,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + unicode.write("\x1b[H\x1b[38;2;0;0;8m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes()); + let unicode_metadata = unicode + .kitty_image_placements_with_data_filter(|descriptor| { + assert_eq!(descriptor.source_file, supported); + false + }) + .unwrap(); + assert_eq!(unicode_metadata.len(), 1); + assert!(unicode_metadata[0].data.is_empty()); + assert_eq!(unicode_metadata[0].source_file.is_some(), supported); + std::fs::write(&path, [99u8; 16]).unwrap(); + assert_eq!(unicode.kitty_image_placements().unwrap()[0].data, bytes); + let compatible = terminal.kitty_image_placements().unwrap(); + assert_eq!(compatible[0].data, bytes); + assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint); + drop(terminal); + if let Some(source) = &metadata[0].source_file { + assert_eq!(source.copy_rgba().unwrap(), bytes); + } + std::fs::remove_file(path).unwrap(); +} diff --git a/src/ghostty/png_forward_tests.rs b/src/ghostty/png_forward_tests.rs new file mode 100644 index 00000000..6d3904b2 --- /dev/null +++ b/src/ghostty/png_forward_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use base64::Engine as _; + +fn fixture() -> Vec<u8> { + fixture_size(32, 16) +} + +fn fixture_size(width: u32, height: u32) -> Vec<u8> { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&vec![42; (width * height * 4) as usize]) + .unwrap(); + } + bytes +} + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.set_kitty_png_forwarding(true).unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + terminal +} + +#[test] +#[ignore = "manual fixed-geometry native PNG ingestion/extraction scaling profile"] +fn native_png_render_scale_profile() { + let png = fixture_size(800, 480); + let upload = format!( + "\x1b[H\x1b_Ga=T,f=100,i=7,c=60,r=20,C=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&png) + ); + for count in [1, 15] { + for forwarding in [false, true] { + let mut panes = (0..count) + .map(|_| { + let mut pane = Terminal::new(120, 40, 0).unwrap(); + pane.enable_kitty_graphics().unwrap(); + pane.set_kitty_png_forwarding(forwarding).unwrap(); + pane.resize(120, 40, 8, 16).unwrap(); + pane + }) + .collect::<Vec<_>>(); + let mut samples = Vec::new(); + for iteration in 0..65 { + let begin = std::time::Instant::now(); + for pane in &mut panes { + pane.write(upload.as_bytes()); + let placements = pane.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!( + placements[0].data.len(), + if forwarding { png.len() } else { 800 * 480 * 4 } + ); + std::hint::black_box(placements); + } + if iteration >= 5 { + samples.push(begin.elapsed().as_micros()); + } + } + samples.sort_unstable(); + eprintln!("native PNG panes={count} forwarding={forwarding} median_us={} p95_us={} png_bytes={}", samples[30], samples[57], png.len()); + } + } +} + +#[test] +fn normal_kitty_graphics_fully_decodes_quiet_png_uploads() { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(fixture()) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + assert_eq!(placements[0].data, vec![42; 32 * 16 * 4]); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); +} + +#[test] +fn quiet_png_forwarding_retains_exact_payload_without_pixel_decode() { + let bytes = fixture(); + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Png); + assert_eq!(placements[0].data, bytes); + assert_eq!( + (placements[0].image_width, placements[0].image_height), + (32, 16) + ); + assert_eq!(PNG_DECODE_CALLS.get(), before); +} + +#[test] +fn placeholder_png_forwarding_and_lazy_animation_decode() { + let bytes = fixture(); + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,U=1,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + terminal.write("\x1b[H\x1b[38;2;0;0;7m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes()); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Png); + assert_eq!(placements[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); + let fingerprint = placements[0].data_fingerprint; + terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\"); + let materialized = terminal.kitty_image_placements().unwrap(); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + assert_eq!(materialized[0].format, KittyImageFormat::Rgba); + assert_eq!(materialized[0].data, [42; 32 * 16 * 4]); + assert_ne!(materialized[0].data_fingerprint, fingerprint); +} + +#[test] +fn experimental_quiet_png_defers_crc_valid_compressed_data_errors() { + let mut bytes = fixture(); + let mut offset = 8; + loop { + let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + if &bytes[offset + 4..offset + 8] == b"IDAT" { + bytes[offset + 8] = 0; // Invalid zlib header, but structurally valid PNG chunks. + let mut crc = !0u32; + for &byte in &bytes[offset + 4..offset + 8 + len] { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1)); + } + } + bytes[offset + 8 + len..offset + 12 + len].copy_from_slice(&(!crc).to_be_bytes()); + break; + } + offset += len + 12; + } + for quiet in [0, 2] { + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + if quiet == 0 { + assert!(placements.is_empty()); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + } else { + assert_eq!(placements[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); + terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\"); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes); + } + } +} + +#[test] +fn response_bearing_png_and_queries_keep_full_validation() { + let bytes = fixture(); + for (action, quiet) in [('T', 0), ('T', 1), ('q', 2)] { + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga={action},f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + assert_eq!( + PNG_DECODE_CALLS.get(), + before + 1, + "action {action}, quiet {quiet}" + ); + let placements = terminal.kitty_image_placements().unwrap(); + if action == 'q' { + assert!(placements.is_empty()); + } else { + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + } + } +} + +#[test] +fn chunked_png_forwarding_inherits_quiet_mode() { + let bytes = fixture(); + let mut terminal = terminal(); + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let split = (encoded.len() / 8) * 4; + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2,m=1;{}\x1b\\", + &encoded[..split] + ) + .as_bytes(), + ); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + terminal.write(format!("\x1b_Gm=0;{}\x1b\\", &encoded[split..]).as_bytes()); + assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); +} |
