From fb9aff54087aa8a606e9260a689986d2bd76f5fa Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 13 May 2026 00:54:24 +0300 Subject: [PATCH] feat: render kitty graphics in local panes --- Cargo.lock | 64 +++++ Cargo.toml | 1 + KITTY_GRAPHICS_PRD.md | 53 ++++ src/app/mod.rs | 28 +- src/ghostty/bindings.rs | 159 +++++++++++ src/ghostty/mod.rs | 434 ++++++++++++++++++++++++++++- src/kitty_graphics.rs | 595 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/pane.rs | 61 ++-- src/pane/terminal.rs | 45 ++- src/ui.rs | 54 +++- src/ui/panes.rs | 69 ++++- 12 files changed, 1510 insertions(+), 54 deletions(-) create mode 100644 KITTY_GRAPHICS_PRD.md create mode 100644 src/kitty_graphics.rs diff --git a/Cargo.lock b/Cargo.lock index e0a310dd..bf6ec176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -186,6 +192,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -393,6 +408,15 @@ dependencies = [ "regex", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -416,6 +440,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -505,6 +539,7 @@ dependencies = [ "crossterm", "ctrlc", "libc", + "png", "portable-pty", "ratatui", "regex", @@ -720,6 +755,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -977,6 +1022,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1364,6 +1422,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 26bd5932..5b5f819c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ crossterm = "0.29" ctrlc = "3" libc = "0.2" portable-pty = "0.9" +png = "0.17" ratatui = "0.30" regex = "1" serde = { version = "1", features = ["derive"] } diff --git a/KITTY_GRAPHICS_PRD.md b/KITTY_GRAPHICS_PRD.md new file mode 100644 index 00000000..178e81ba --- /dev/null +++ b/KITTY_GRAPHICS_PRD.md @@ -0,0 +1,53 @@ +# Kitty graphics support PRD + +## Goal + +Herdr should let pane programs use the Kitty graphics protocol without raw passthrough. Each pane remains a normal libghostty-backed terminal. Herdr reads pane-local image state from libghostty, translates it into the composed outer terminal layout, and paints images in the host terminal. + +## Non-goals for the first phase + +The first phase does not make the headless server/client protocol image-aware. It does not preserve images across detach/reattach. It does not guarantee scrollback image history. It does not support file, temporary-file, or shared-memory image media. It does not support arbitrary non-pane UI images. + +## Task 1: local pane graphics + +Visible panes in the attached local UI can display Kitty graphics. A hidden workspace or tab may accumulate libghostty image state while hidden; when it becomes visible, herdr repaints the visible placements for that pane. Pane splits, tab bar, sidebar, borders, zoom, and resizing are handled by mapping pane-local viewport coordinates into the outer terminal coordinates. + +Acceptance criteria: + +- Each visible pane in the active workspace/tab can receive direct Kitty image commands and render the image in its pane. +- Hidden panes do not paint into the host terminal while hidden. +- Switching tabs/workspaces clears stale host-terminal images and paints the newly visible pane placements. +- Resizing, splitting, closing, or zooming panes clears stale placements and repaints from libghostty state. +- Herdr does not forward raw pane Kitty escape sequences to the outer terminal. +- Image storage remains pane-local in libghostty and host-terminal image IDs are namespaced by herdr. +- File, temp-file, and shared-memory media stay disabled. + +Implementation shape: + +- Bind libghostty Kitty graphics and system APIs used by the embedder. +- Install a process-global PNG decoder if PNG payload support is enabled. +- Enable a bounded Kitty image storage limit on pane terminals. +- Pass real host cell pixel geometry to libghostty resize and PTY resize. +- After the ratatui text frame is drawn, query visible pane placements, clip to each pane inner rect, and emit host Kitty graphics commands for those placements. +- Clear herdr-owned host image IDs before repainting to avoid stale images after layout changes. + +## Task 2: multiplexer-complete graphics + +Detached clients, reattached clients, multiple clients, scrollback image history, and client-specific host-terminal caches are supported. This is the hard tmux-class part because herdr must synchronize image state across clients that have independent terminal image caches. + +Acceptance criteria: + +- A newly attached client receives enough image metadata/data to reconstruct the current visible placements without waiting for the pane app to redraw. +- Multiple attached clients can render the same pane image state with separate host-terminal image ID namespaces. +- Scrolling a pane moves, clips, hides, and reveals images consistently with text scrollback. +- Hidden tabs/workspaces retain state without leaking host-terminal images into visible clients. +- Server/client protocol has explicit image messages or an equivalent terminal-graphics stream with size limits and cache lifecycle. +- Images are deleted when panes close, workspaces disappear, clients detach, or host-terminal geometry invalidates placements. + +Implementation shape: + +- Extend the headless protocol beyond `FrameData` cells to carry image descriptors and payloads or pre-encoded Kitty graphics commands. +- Track per-client image caches and ID allocation. +- Add viewport-aware image rendering for scrollback, including virtual/placeholder placements if required. +- Add backpressure and size limits for large images. +- Add tests around tab switches, detach/reattach, multiple clients, and scrollback. diff --git a/src/app/mod.rs b/src/app/mod.rs index e9d0b41e..0719dc0c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,7 +18,7 @@ mod theme_sync; use std::collections::{HashMap, HashSet}; use std::future::pending; -use std::io; +use std::io::{self, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -90,6 +90,25 @@ pub(crate) enum LoopEvent { RenderRequested, } +struct SyncOutputGuard; + +impl SyncOutputGuard { + fn begin() -> io::Result { + let mut stdout = io::stdout().lock(); + stdout.write_all(b"\x1b[?2026h")?; + stdout.flush()?; + Ok(Self) + } +} + +impl Drop for SyncOutputGuard { + fn drop(&mut self) { + let mut stdout = io::stdout().lock(); + let _ = stdout.write_all(b"\x1b[?2026l"); + let _ = stdout.flush(); + } +} + async fn recv_raw_input_or_pending( input_rx: Option<&mut mpsc::Receiver>, ) -> Option { @@ -479,10 +498,15 @@ impl App { if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); + let _sync_output = SyncOutputGuard::begin()?; + let mut cell_size = crate::kitty_graphics::HostCellSize::default(); terminal.draw(|frame| { - crate::ui::compute_view(&mut self.state, frame.area()); + let area = frame.area(); + cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area); + crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); crate::ui::render(&self.state, frame); })?; + crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; self.last_render_at = Some(now); needs_render = false; continue; diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index 6e77288f..4998e2b6 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -2505,3 +2505,162 @@ unsafe extern "C" { #[doc = " Check if paste data is safe to paste into the terminal.\n\n Data is considered unsafe if it contains:\n - Newlines (`\\n`) which can inject commands\n - The bracketed paste end sequence (`\\x1b[201~`) which can be used\n to exit bracketed paste mode and inject commands\n\n This check is conservative and considers data unsafe regardless of\n current terminal state.\n\n @param data The paste data to check (must not be NULL)\n @param len The length of the data in bytes\n @return true if the data is safe to paste, false otherwise"] pub fn ghostty_paste_is_safe(data: *const ::std::os::raw::c_char, len: usize) -> bool; } + +// Manually maintained additions for the vendored libghostty-vt Kitty graphics +// C API. The rest of this file is generated by bindgen; keep these constants +// in sync with vendor/libghostty-vt/include/ghostty/vt/*.h until bindings are +// regenerated from the current vendored headers. +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT: + GhosttyTerminalOption = 15; +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE: + GhosttyTerminalOption = 16; +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE: + GhosttyTerminalOption = 17; +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM: + GhosttyTerminalOption = 18; +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES: GhosttyTerminalOption = 19; +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY: GhosttyTerminalOption = + 20; + +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS: GhosttyTerminalData = 30; + +pub type GhosttySysOption = ::std::os::raw::c_uint; +pub const GhosttySysOption_GHOSTTY_SYS_OPT_DECODE_PNG: GhosttySysOption = 1; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsImpl { + _unused: [u8; 0], +} +pub type GhosttyKittyGraphics = *mut GhosttyKittyGraphicsImpl; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsImageImpl { + _unused: [u8; 0], +} +pub type GhosttyKittyGraphicsImage = *const GhosttyKittyGraphicsImageImpl; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsPlacementIteratorImpl { + _unused: [u8; 0], +} +pub type GhosttyKittyGraphicsPlacementIterator = *mut GhosttyKittyGraphicsPlacementIteratorImpl; + +pub type GhosttyKittyGraphicsData = ::std::os::raw::c_uint; +pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR: + GhosttyKittyGraphicsData = 1; + +pub type GhosttyKittyGraphicsPlacementData = ::std::os::raw::c_uint; +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID: + GhosttyKittyGraphicsPlacementData = 1; +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID: + GhosttyKittyGraphicsPlacementData = 2; +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET: + GhosttyKittyGraphicsPlacementData = 4; +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET: + GhosttyKittyGraphicsPlacementData = 5; +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z: + GhosttyKittyGraphicsPlacementData = 12; + +pub type GhosttyKittyGraphicsImageData = ::std::os::raw::c_uint; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_WIDTH: + GhosttyKittyGraphicsImageData = 3; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_HEIGHT: + GhosttyKittyGraphicsImageData = 4; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT: + GhosttyKittyGraphicsImageData = 5; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION: + GhosttyKittyGraphicsImageData = 6; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR: + GhosttyKittyGraphicsImageData = 7; +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN: + GhosttyKittyGraphicsImageData = 8; + +pub type GhosttyKittyImageFormat = ::std::os::raw::c_uint; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGB: GhosttyKittyImageFormat = 0; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA: GhosttyKittyImageFormat = 1; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_PNG: GhosttyKittyImageFormat = 2; + +pub type GhosttyKittyImageCompression = ::std::os::raw::c_uint; +pub const GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE: + GhosttyKittyImageCompression = 0; + +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyKittyGraphicsPlacementRenderInfo { + pub size: usize, + pub pixel_width: u32, + pub pixel_height: u32, + pub grid_cols: u32, + pub grid_rows: u32, + pub viewport_col: i32, + pub viewport_row: i32, + pub viewport_visible: bool, + pub source_x: u32, + pub source_y: u32, + pub source_width: u32, + pub source_height: u32, +} + +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttySysImage { + pub width: u32, + pub height: u32, + pub data: *mut u8, + pub data_len: usize, +} + +pub type GhosttySysDecodePngFn = ::std::option::Option< + unsafe extern "C" fn( + userdata: *mut ::std::os::raw::c_void, + allocator: *const GhosttyAllocator, + data: *const u8, + data_len: usize, + out: *mut GhosttySysImage, + ) -> bool, +>; + +unsafe extern "C" { + pub fn ghostty_sys_set( + option: GhosttySysOption, + value: *const ::std::os::raw::c_void, + ) -> GhosttyResult; + pub fn ghostty_kitty_graphics_get( + graphics: GhosttyKittyGraphics, + data: GhosttyKittyGraphicsData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; + pub fn ghostty_kitty_graphics_image( + graphics: GhosttyKittyGraphics, + image_id: u32, + ) -> GhosttyKittyGraphicsImage; + pub fn ghostty_kitty_graphics_image_get( + image: GhosttyKittyGraphicsImage, + data: GhosttyKittyGraphicsImageData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; + pub fn ghostty_kitty_graphics_placement_iterator_new( + allocator: *const GhosttyAllocator, + out_iterator: *mut GhosttyKittyGraphicsPlacementIterator, + ) -> GhosttyResult; + pub fn ghostty_kitty_graphics_placement_iterator_free( + iterator: GhosttyKittyGraphicsPlacementIterator, + ); + pub fn ghostty_kitty_graphics_placement_next( + iterator: GhosttyKittyGraphicsPlacementIterator, + ) -> bool; + pub fn ghostty_kitty_graphics_placement_get( + iterator: GhosttyKittyGraphicsPlacementIterator, + data: GhosttyKittyGraphicsPlacementData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; + pub fn ghostty_kitty_graphics_placement_render_info( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal_ptr, + out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo, + ) -> GhosttyResult; +} diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index bc21ab99..d3a67540 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -17,6 +17,7 @@ use std::mem; use std::os::raw::c_char; use std::ptr; use std::slice; +use std::sync::Once; pub use bindings as ffi; @@ -142,6 +143,47 @@ pub const MODE_MOUSE_ALTERNATE_SCROLL: u16 = 1007; pub const MODE_BRACKETED_PASTE: u16 = 2004; 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; + +static INSTALL_PNG_DECODER: Once = Once::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KittyImageFormat { + Rgb, + Rgba, + Png, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KittyImagePlacement { + pub image_id: u32, + pub placement_id: u32, + pub z: i32, + pub x_offset: u32, + pub y_offset: u32, + pub image_width: u32, + pub image_height: u32, + pub format: KittyImageFormat, + pub data: Vec, + pub render: KittyPlacementRenderInfo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyPlacementRenderInfo { + pub pixel_width: u32, + pub pixel_height: u32, + pub grid_cols: u32, + pub grid_rows: u32, + pub viewport_col: i32, + pub viewport_row: i32, + pub source_x: u32, + pub source_y: u32, + pub source_width: u32, + pub source_height: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveScreen { Primary, @@ -278,6 +320,95 @@ unsafe extern "C" fn write_pty_trampoline( (state.callback)(bytes); } +fn install_png_decoder_once() { + INSTALL_PNG_DECODER.call_once(|| unsafe { + let _ = ffi::ghostty_sys_set( + ffi::GhosttySysOption_GHOSTTY_SYS_OPT_DECODE_PNG, + (decode_png_trampoline as *const ()).cast(), + ); + }); +} + +unsafe extern "C" fn decode_png_trampoline( + _userdata: *mut c_void, + allocator: *const ffi::GhosttyAllocator, + data: *const u8, + data_len: usize, + out: *mut ffi::GhosttySysImage, +) -> bool { + if data.is_null() || out.is_null() { + return false; + } + let bytes = unsafe { slice::from_raw_parts(data, data_len) }; + let Some(rgba) = decode_png_rgba(bytes) else { + return false; + }; + let ptr = unsafe { ffi::ghostty_alloc(allocator, rgba.data.len()) }; + if ptr.is_null() { + return false; + } + unsafe { + ptr::copy_nonoverlapping(rgba.data.as_ptr(), ptr, rgba.data.len()); + *out = ffi::GhosttySysImage { + width: rgba.width, + height: rgba.height, + data: ptr, + data_len: rgba.data.len(), + }; + } + true +} + +struct DecodedPng { + width: u32, + height: u32, + data: Vec, +} + +fn decode_png_rgba(bytes: &[u8]) -> Option { + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().ok()?; + let mut buf = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).ok()?; + let frame = &buf[..info.buffer_size()]; + if info.bit_depth != png::BitDepth::Eight { + return None; + } + + let data = match info.color_type { + png::ColorType::Rgba => frame.to_vec(), + png::ColorType::Rgb => { + let mut out = Vec::with_capacity((info.width as usize) * (info.height as usize) * 4); + for rgb in frame.chunks_exact(3) { + out.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]); + } + out + } + png::ColorType::Grayscale => { + let mut out = Vec::with_capacity((info.width as usize) * (info.height as usize) * 4); + for gray in frame { + out.extend_from_slice(&[*gray, *gray, *gray, 255]); + } + out + } + png::ColorType::GrayscaleAlpha => { + let mut out = Vec::with_capacity((info.width as usize) * (info.height as usize) * 4); + for ga in frame.chunks_exact(2) { + out.extend_from_slice(&[ga[0], ga[0], ga[0], ga[1]]); + } + out + } + png::ColorType::Indexed => return None, + }; + + Some(DecodedPng { + width: info.width, + height: info.height, + data, + }) +} + pub fn encode_focus(event: FocusEvent) -> Result, Error> { let mut required = 0usize; // SAFETY: null buffer + out len is the documented way to query required size. @@ -332,9 +463,63 @@ impl Terminal { } } - pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), Error> { + pub fn resize( + &mut self, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + ) -> Result<(), Error> { // SAFETY: self.raw is valid and sizes are plain values. - unsafe { ffi::ghostty_terminal_resize(self.raw, cols, rows, 0, 0).into_result() } + unsafe { + ffi::ghostty_terminal_resize(self.raw, cols, rows, cell_width_px, cell_height_px) + .into_result() + } + } + + pub fn enable_kitty_graphics(&mut self) -> Result<(), Error> { + install_png_decoder_once(); + let storage_limit = KITTY_IMAGE_STORAGE_LIMIT_BYTES; + let disable_medium = false; + unsafe { + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + (&storage_limit as *const u64).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE, + (&disable_medium as *const bool).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE, + (&disable_medium as *const bool).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM, + (&disable_medium as *const bool).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES, + (&APC_MAX_BYTES as *const usize).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY, + (&APC_MAX_BYTES_KITTY as *const usize).cast(), + ) + .into_result()?; + } + Ok(()) } pub fn set_write_pty_callback(&mut self, callback: F) -> Result<(), Error> @@ -651,11 +836,145 @@ impl Terminal { Ok(out) } + pub fn kitty_image_placements(&self) -> Result, Error> { + let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut(); + unsafe { + ffi::ghostty_terminal_get( + self.raw, + ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + (&mut graphics as *mut ffi::GhosttyKittyGraphics).cast(), + ) + .into_result()?; + } + if graphics.is_null() { + return Ok(Vec::new()); + } + + let mut iterator: ffi::GhosttyKittyGraphicsPlacementIterator = ptr::null_mut(); + unsafe { + ffi::ghostty_kitty_graphics_placement_iterator_new(ptr::null(), &mut iterator) + .into_result()?; + ffi::ghostty_kitty_graphics_get( + graphics, + ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, + (&mut iterator as *mut ffi::GhosttyKittyGraphicsPlacementIterator).cast(), + ) + .into_result()?; + } + let _guard = KittyPlacementIteratorGuard { raw: iterator }; + + let mut placements = Vec::new(); + while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } { + if let Some(placement) = self.kitty_image_placement(graphics, iterator)? { + placements.push(placement); + } + } + placements.sort_by_key(|placement| placement.z); + Ok(placements) + } + + fn kitty_image_placement( + &self, + graphics: ffi::GhosttyKittyGraphics, + iterator: ffi::GhosttyKittyGraphicsPlacementIterator, + ) -> Result, Error> { + let image_id = kitty_placement_u32( + iterator, + ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, + )?; + let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) }; + if image.is_null() { + return Ok(None); + } + + let mut raw_info = ffi::GhosttyKittyGraphicsPlacementRenderInfo { + size: mem::size_of::(), + ..Default::default() + }; + unsafe { + ffi::ghostty_kitty_graphics_placement_render_info( + iterator, + image, + self.raw, + &mut raw_info, + ) + .into_result()?; + } + if !raw_info.viewport_visible { + return Ok(None); + } + + let image_width = kitty_image_u32( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_WIDTH, + )?; + let image_height = kitty_image_u32( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, + )?; + let format = kitty_image_format(image)?; + let compression = kitty_image_compression(image)?; + if compression != ffi::GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE { + return Ok(None); + } + let data = kitty_image_data(image)?; + let placement_id = kitty_placement_u32( + iterator, + ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, + )?; + let x_offset = kitty_placement_u32( + iterator, + ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, + )?; + let y_offset = kitty_placement_u32( + iterator, + ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET, + )?; + let z = kitty_placement_i32( + iterator, + ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z, + )?; + + Ok(Some(KittyImagePlacement { + image_id, + placement_id, + z, + x_offset, + y_offset, + image_width, + image_height, + format, + data, + render: KittyPlacementRenderInfo { + pixel_width: raw_info.pixel_width, + pixel_height: raw_info.pixel_height, + grid_cols: raw_info.grid_cols, + grid_rows: raw_info.grid_rows, + viewport_col: raw_info.viewport_col, + viewport_row: raw_info.viewport_row, + source_x: raw_info.source_x, + source_y: raw_info.source_y, + source_width: raw_info.source_width, + source_height: raw_info.source_height, + }, + })) + } + fn raw(&self) -> ffi::GhosttyTerminal_ptr { self.raw } } +struct KittyPlacementIteratorGuard { + raw: ffi::GhosttyKittyGraphicsPlacementIterator, +} + +impl Drop for KittyPlacementIteratorGuard { + fn drop(&mut self) { + unsafe { ffi::ghostty_kitty_graphics_placement_iterator_free(self.raw) } + } +} + // SAFETY: these opaque handles are only used behind external synchronization in pane runtime. unsafe impl Send for Terminal {} @@ -705,6 +1024,98 @@ fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> Ok(buffer) } +fn kitty_placement_u32( + iterator: ffi::GhosttyKittyGraphicsPlacementIterator, + data: ffi::GhosttyKittyGraphicsPlacementData, +) -> Result { + let mut out = 0u32; + unsafe { + ffi::ghostty_kitty_graphics_placement_get(iterator, data, (&mut out as *mut u32).cast()) + .into_result()?; + } + Ok(out) +} + +fn kitty_placement_i32( + iterator: ffi::GhosttyKittyGraphicsPlacementIterator, + data: ffi::GhosttyKittyGraphicsPlacementData, +) -> Result { + let mut out = 0i32; + unsafe { + ffi::ghostty_kitty_graphics_placement_get(iterator, data, (&mut out as *mut i32).cast()) + .into_result()?; + } + Ok(out) +} + +fn kitty_image_u32( + image: ffi::GhosttyKittyGraphicsImage, + data: ffi::GhosttyKittyGraphicsImageData, +) -> Result { + let mut out = 0u32; + unsafe { + ffi::ghostty_kitty_graphics_image_get(image, data, (&mut out as *mut u32).cast()) + .into_result()?; + } + Ok(out) +} + +fn kitty_image_format(image: ffi::GhosttyKittyGraphicsImage) -> Result { + let mut out = ffi::GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT, + (&mut out as *mut ffi::GhosttyKittyImageFormat).cast(), + ) + .into_result()?; + } + match out { + ffi::GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGB => Ok(KittyImageFormat::Rgb), + ffi::GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA => Ok(KittyImageFormat::Rgba), + ffi::GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_PNG => Ok(KittyImageFormat::Png), + _ => Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE)), + } +} + +fn kitty_image_compression( + image: ffi::GhosttyKittyGraphicsImage, +) -> Result { + let mut out = ffi::GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION, + (&mut out as *mut ffi::GhosttyKittyImageCompression).cast(), + ) + .into_result()?; + } + Ok(out) +} + +fn kitty_image_data(image: ffi::GhosttyKittyGraphicsImage) -> Result, Error> { + let mut ptr_out: *const u8 = ptr::null(); + let mut len = 0usize; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR, + (&mut ptr_out as *mut *const u8).cast(), + ) + .into_result()?; + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, + (&mut len as *mut usize).cast(), + ) + .into_result()?; + } + if ptr_out.is_null() || len == 0 { + return Ok(Vec::new()); + } + Ok(unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec()) +} + fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> { let mut required = 0usize; let result = @@ -1393,6 +1804,25 @@ mod tests { )); } + #[test] + fn kitty_graphics_direct_rgba_placement_is_queryable() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(10, 5, 8, 16).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 placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].image_id, 7); + assert_eq!(placements[0].placement_id, 3); + assert_eq!(placements[0].image_width, 1); + assert_eq!(placements[0].image_height, 1); + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + assert_eq!(placements[0].data, [255, 0, 0, 255]); + assert_eq!(placements[0].render.grid_cols, 10); + assert_eq!(placements[0].render.grid_rows, 5); + } + #[test] fn focus_encoding_matches_expected_sequences() { assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I"); diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs new file mode 100644 index 00000000..3dc96e67 --- /dev/null +++ b/src/kitty_graphics.rs @@ -0,0 +1,595 @@ +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write as FmtWrite; +use std::hash::{Hash, Hasher}; +use std::io::{self, Write}; +use std::sync::{Mutex, OnceLock}; + +use base64::Engine; +use ratatui::layout::Rect; + +use crate::app::state::AppState; +use crate::app::Mode; +use crate::ghostty::{KittyImageFormat, KittyImagePlacement}; +use crate::layout::PaneId; + +const KITTY_CHUNK_BYTES: usize = 3072; +const HOST_IMAGE_ID_BASE: u32 = 10_000; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct HostCellSize { + pub width_px: u32, + pub height_px: u32, +} + +impl HostCellSize { + pub(crate) fn from_terminal(area: Rect) -> Self { + let Ok(size) = crossterm::terminal::window_size() else { + return Self::fallback_for_area(area); + }; + if size.columns == 0 || size.rows == 0 { + return Self::fallback_for_area(area); + } + if size.width == 0 || size.height == 0 { + return Self::fallback_for_area(area); + } + Self { + width_px: (size.width as u32 / size.columns as u32).max(1), + height_px: (size.height as u32 / size.rows as u32).max(1), + } + .for_area(area) + } + + pub(crate) fn is_known(self) -> bool { + self.width_px > 0 && self.height_px > 0 + } + + fn fallback_for_area(area: Rect) -> Self { + Self { + width_px: 8, + height_px: 16, + } + .for_area(area) + } + + fn for_area(self, area: Rect) -> Self { + if area.width == 0 || area.height == 0 { + return Self::default(); + } + self + } +} + +#[derive(Debug)] +struct HostPlacement { + pane_id: PaneId, + area: Rect, + cell_size: HostCellSize, + placement: KittyImagePlacement, + scrollback_offset: u32, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +struct ImageSignature { + image_width: u32, + image_height: u32, + format_code: u32, + data_len: usize, + data_hash: u64, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +struct PlacementSignature { + x: u16, + y: u16, + cols: u32, + rows: u32, + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, + x_offset: u32, + y_offset: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClippedPlacement { + x: u16, + y: u16, + cols: u32, + rows: u32, + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, + x_offset: u32, + y_offset: u32, +} + +static HOST_IMAGES: OnceLock>> = OnceLock::new(); +static HOST_PLACEMENTS: OnceLock>> = OnceLock::new(); + +pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) -> io::Result<()> { + let mode_ok = app.mode == Mode::Terminal; + let cell_ok = cell_size.is_known(); + tracing::debug!( + mode_ok, + cell_ok, + cell_width_px = cell_size.width_px, + cell_height_px = cell_size.height_px, + active = ?app.active, + pane_infos_len = app.view.pane_infos.len(), + "paint_local_pane_graphics entry" + ); + if !mode_ok || !cell_ok { + tracing::debug!( + reason = if !mode_ok { + "not terminal mode" + } else { + "cell size unknown" + }, + "paint_local_pane_graphics early return" + ); + return clear_all_host_images(); + } + + let placements = collect_visible_placements(app, cell_size); + tracing::debug!( + placements_collected = placements.len(), + "collect_visible_placements result" + ); + let mut bytes = Vec::new(); + let mut current_placements = HashSet::new(); + + let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new())); + let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new())); + if let (Ok(mut host_images), Ok(mut host_placements)) = + (image_state.lock(), placement_state.lock()) + { + for placement in &placements { + let clipped = clipped_placement(placement); + tracing::debug!( + pane_id = ?placement.pane_id, + has_clipped = clipped.is_some(), + grid_cols = placement.placement.render.grid_cols, + grid_rows = placement.placement.render.grid_rows, + viewport_col = placement.placement.render.viewport_col, + viewport_row = placement.placement.render.viewport_row, + area_w = placement.area.width, + area_h = placement.area.height, + "clipped_placement result" + ); + let Some((clipped, format_code)) = clipped else { + continue; + }; + 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); + let placement_key = (host_id, host_placement_id); + current_placements.insert(placement_key); + + match host_images.get(&host_id).copied() { + Some(existing) if existing == image_signature => {} + Some(_) => { + encode_delete_image(&mut bytes, host_id); + host_placements.retain(|(image_id, _), _| *image_id != host_id); + encode_upload_image(&mut bytes, placement, format_code, host_id); + host_images.insert(host_id, image_signature); + } + None => { + encode_upload_image(&mut bytes, placement, format_code, host_id); + host_images.insert(host_id, image_signature); + } + } + + match host_placements.get_mut(&placement_key) { + Some(existing) if *existing == placement_signature => {} + Some(existing) => { + encode_display_placement( + &mut bytes, + clipped, + host_id, + host_placement_id, + placement.placement.z, + ); + *existing = placement_signature; + } + None => { + encode_display_placement( + &mut bytes, + clipped, + host_id, + host_placement_id, + placement.placement.z, + ); + host_placements.insert(placement_key, placement_signature); + } + } + } + + let stale_placements: Vec<(u32, u32)> = host_placements + .keys() + .copied() + .filter(|key| !current_placements.contains(key)) + .collect(); + for (host_id, host_placement_id) in stale_placements { + encode_delete_placement(&mut bytes, host_id, host_placement_id); + host_placements.remove(&(host_id, host_placement_id)); + } + } + + if bytes.is_empty() { + return Ok(()); + } + + tracing::debug!( + placements = placements.len(), + bytes = bytes.len(), + cell_width_px = cell_size.width_px, + cell_height_px = cell_size.height_px, + "painting kitty graphics placements" + ); + + let mut framed = Vec::with_capacity(bytes.len() + 8); + framed.extend_from_slice(b"\x1b7"); + framed.extend_from_slice(&bytes); + framed.extend_from_slice(b"\x1b8"); + + let mut stdout = io::stdout().lock(); + stdout.write_all(&framed)?; + stdout.flush() +} + +fn clear_all_host_images() -> io::Result<()> { + let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new())); + let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new())); + let mut bytes = Vec::new(); + if let (Ok(mut host_images), Ok(mut host_placements)) = + (image_state.lock(), placement_state.lock()) + { + for id in host_images.keys().copied().collect::>() { + encode_delete_image(&mut bytes, id); + } + host_images.clear(); + host_placements.clear(); + } + if bytes.is_empty() { + return Ok(()); + } + let mut stdout = io::stdout().lock(); + stdout.write_all(&bytes)?; + stdout.flush() +} + +fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec { + let ws_idx = match app.active { + Some(idx) => idx, + None => { + tracing::debug!("collect_visible_placements: no active workspace"); + return Vec::new(); + } + }; + let tab = match app + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::active_tab) + { + Some(t) => t, + None => { + tracing::debug!(ws_idx, "collect_visible_placements: no active tab"); + return Vec::new(); + } + }; + + tracing::debug!( + ws_idx, + tab_runtimes_len = tab.runtimes.len(), + pane_infos_len = app.view.pane_infos.len(), + "collect_visible_placements: starting iteration" + ); + let mut placements = Vec::new(); + for info in &app.view.pane_infos { + let runtime = match tab.runtimes.get(&info.id) { + Some(rt) => rt, + None => { + tracing::debug!(pane_id = ?info.id, "collect_visible_placements: runtime not found"); + continue; + } + }; + for placement in runtime.kitty_image_placements() { + let scrollback_offset = runtime + .scroll_metrics() + .map(|m| m.offset_from_bottom as u32) + .unwrap_or(0); + placements.push(HostPlacement { + pane_id: info.id, + area: info.inner_rect, + cell_size, + placement, + scrollback_offset, + }); + } + } + tracing::debug!( + placements_len = placements.len(), + "collect_visible_placements: done" + ); + placements +} + +fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 { + let mut hasher = DefaultHasher::new(); + pane_id.raw().hash(&mut hasher); + placement.image_id.hash(&mut hasher); + HOST_IMAGE_ID_BASE + ((hasher.finish() as u32) % 900_000) +} + +fn host_placement_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 { + let mut hasher = DefaultHasher::new(); + pane_id.raw().hash(&mut hasher); + placement.image_id.hash(&mut hasher); + placement.placement_id.hash(&mut hasher); + 1 + ((hasher.finish() as u32) % 900_000) +} + +fn encode_delete_image(out: &mut Vec, id: u32) { + let _ = write!(out, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\"); +} + +fn encode_delete_placement(out: &mut Vec, host_id: u32, host_placement_id: u32) { + let _ = write!( + out, + "\x1b_Ga=d,d=i,i={host_id},p={host_placement_id},q=2;\x1b\\" + ); +} + +fn encode_upload_image( + out: &mut Vec, + placement: &HostPlacement, + format_code: u32, + host_id: u32, +) { + if placement.placement.data.is_empty() { + return; + } + + let control = format!( + "a=t,t=d,f={format_code},s={},v={},i={host_id},q=2", + placement.placement.image_width, placement.placement.image_height, + ); + encode_kitty_data(out, &control, &placement.placement.data); +} + +fn encode_display_placement( + out: &mut Vec, + clipped: ClippedPlacement, + host_id: u32, + host_placement_id: u32, + z: i32, +) { + let _ = write!(out, "\x1b[{};{}H", clipped.y + 1, clipped.x + 1); + let mut control = format!( + "a=p,i={host_id},p={host_placement_id},c={},r={},z={z},C=1,q=2", + clipped.cols, clipped.rows, + ); + if clipped.source_x > 0 { + let _ = write!(control, ",x={}", clipped.source_x); + } + if clipped.source_y > 0 { + let _ = write!(control, ",y={}", clipped.source_y); + } + if clipped.source_width > 0 { + let _ = write!(control, ",w={}", clipped.source_width); + } + if clipped.source_height > 0 { + let _ = write!(control, ",h={}", clipped.source_height); + } + if clipped.x_offset > 0 { + let _ = write!(control, ",X={}", clipped.x_offset); + } + if clipped.y_offset > 0 { + let _ = write!(control, ",Y={}", clipped.y_offset); + } + + let _ = write!(out, "\x1b_G{control};\x1b\\"); +} + +fn clipped_placement(placement: &HostPlacement) -> Option<(ClippedPlacement, u32)> { + if placement.area.width == 0 || placement.area.height == 0 { + tracing::debug!( + area_w = placement.area.width, + area_h = placement.area.height, + "clipped_placement: area zero" + ); + return None; + } + let render = placement.placement.render; + if render.grid_cols == 0 || render.grid_rows == 0 { + tracing::debug!( + grid_cols = render.grid_cols, + grid_rows = render.grid_rows, + "clipped_placement: grid zero" + ); + return None; + } + let format_code = kitty_format_code(placement.placement.format); + + let left_clip_cells = if render.viewport_col < 0 { + render.viewport_col.saturating_neg() as u32 + } else { + 0 + }; + let top_clip_cells = if render.viewport_row < 0 { + render.viewport_row.saturating_neg() as u32 + } else { + 0 + }; + let viewport_col = render.viewport_col.max(0) as u32; + let viewport_row = render.viewport_row.max(0) as u32; + tracing::debug!( + viewport_col = viewport_col, + viewport_row = viewport_row, + area_w = placement.area.width, + area_h = placement.area.height, + scrollback_offset = placement.scrollback_offset, + raw_viewport_row = render.viewport_row, + cond1 = viewport_col >= placement.area.width as u32, + cond2 = viewport_row >= placement.area.height as u32, + "clipped_placement: viewport check" + ); + if viewport_col >= placement.area.width as u32 || viewport_row >= placement.area.height as u32 { + return None; + } + + let visible_cols = render + .grid_cols + .saturating_sub(left_clip_cells) + .min(placement.area.width as u32 - viewport_col); + let visible_rows = render + .grid_rows + .saturating_sub(top_clip_cells) + .min(placement.area.height as u32 - viewport_row); + tracing::debug!( + visible_cols = visible_cols, + visible_rows = visible_rows, + left_clip_cells = left_clip_cells, + top_clip_cells = top_clip_cells, + "clipped_placement: visible dims check" + ); + if visible_cols == 0 || visible_rows == 0 { + return None; + } + + let source_width = if render.source_width == 0 { + placement.placement.image_width + } else { + render.source_width + }; + let source_height = if render.source_height == 0 { + placement.placement.image_height + } else { + render.source_height + }; + let pixel_width = render + .pixel_width + .max( + render + .grid_cols + .saturating_mul(placement.cell_size.width_px), + ) + .max(1); + let pixel_height = render + .pixel_height + .max( + render + .grid_rows + .saturating_mul(placement.cell_size.height_px), + ) + .max(1); + + let crop_left_px = left_clip_cells.saturating_mul(placement.cell_size.width_px); + let crop_top_px = top_clip_cells.saturating_mul(placement.cell_size.height_px); + let visible_width_px = visible_cols.saturating_mul(placement.cell_size.width_px); + let visible_height_px = visible_rows.saturating_mul(placement.cell_size.height_px); + + let source_x = render.source_x + scale_pixels(crop_left_px, source_width, pixel_width); + let source_y = render.source_y + scale_pixels(crop_top_px, source_height, pixel_height); + let source_width = scale_pixels(visible_width_px, source_width, pixel_width) + .max(1) + .min(placement.placement.image_width.saturating_sub(source_x)); + let source_height = scale_pixels(visible_height_px, source_height, pixel_height) + .max(1) + .min(placement.placement.image_height.saturating_sub(source_y)); + + if source_width == 0 || source_height == 0 { + tracing::debug!( + source_width = source_width, + source_height = source_height, + image_width = placement.placement.image_width, + image_height = placement.placement.image_height, + "clipped_placement: source dims zero" + ); + return None; + } + + tracing::debug!("clipped_placement: success"); + Some(( + ClippedPlacement { + x: placement.area.x + viewport_col as u16, + y: placement.area.y + viewport_row as u16, + cols: visible_cols, + rows: visible_rows, + source_x, + source_y, + source_width, + source_height, + x_offset: if left_clip_cells == 0 { + placement.placement.x_offset + } else { + 0 + }, + y_offset: if top_clip_cells == 0 { + placement.placement.y_offset + } else { + 0 + }, + }, + format_code, + )) +} + +fn scale_pixels(value: u32, source: u32, dest: u32) -> u32 { + ((value as u64).saturating_mul(source as u64) / dest.max(1) as u64).min(u32::MAX as u64) as u32 +} + +fn image_signature(placement: &HostPlacement, format_code: u32) -> ImageSignature { + let mut hasher = DefaultHasher::new(); + placement.placement.data.hash(&mut hasher); + ImageSignature { + image_width: placement.placement.image_width, + image_height: placement.placement.image_height, + format_code, + data_len: placement.placement.data.len(), + data_hash: hasher.finish(), + } +} + +fn placement_signature(clipped: ClippedPlacement) -> PlacementSignature { + PlacementSignature { + x: clipped.x, + y: clipped.y, + cols: clipped.cols, + rows: clipped.rows, + source_x: clipped.source_x, + source_y: clipped.source_y, + source_width: clipped.source_width, + source_height: clipped.source_height, + x_offset: clipped.x_offset, + y_offset: clipped.y_offset, + } +} + +fn kitty_format_code(format: KittyImageFormat) -> u32 { + match format { + KittyImageFormat::Rgb => 24, + KittyImageFormat::Rgba => 32, + KittyImageFormat::Png => 100, + } +} + +fn encode_kitty_data(out: &mut Vec, control: &str, data: &[u8]) { + let mut chunks = data.chunks(KITTY_CHUNK_BYTES).peekable(); + let Some(first) = chunks.next() else { + return; + }; + let more = if chunks.peek().is_some() { 1 } else { 0 }; + let encoded = base64::engine::general_purpose::STANDARD.encode(first); + let _ = write!(out, "\x1b_G{control},m={more};{encoded}\x1b\\"); + + while let Some(chunk) = chunks.next() { + let more = if chunks.peek().is_some() { 1 } else { 0 }; + let encoded = base64::engine::general_purpose::STANDARD.encode(chunk); + let _ = write!(out, "\x1b_Gm={more};{encoded}\x1b\\"); + } +} diff --git a/src/main.rs b/src/main.rs index dceb1230..18e6138a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ mod ghostty; mod input; mod integration; mod ipc; +mod kitty_graphics; mod layout; mod logging; mod pane; diff --git a/src/pane.rs b/src/pane.rs index 98edf403..e9a35357 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -162,8 +162,8 @@ impl AgentDetectionPresence { pub struct PaneRuntime { terminal: Arc, sender: mpsc::Sender, - resize_tx: watch::Sender<(u16, u16)>, - current_size: Cell<(u16, u16)>, + resize_tx: watch::Sender<(u16, u16, u32, u32)>, + current_size: Cell<(u16, u16, u32, u32)>, child_pid: Arc, kitty_keyboard_flags: Arc, detect_reset_notify: Arc, @@ -355,7 +355,10 @@ impl PaneRuntime { crate::logging::pane_spawn_started(pane_id.raw(), rows, cols, scrollback_limit_bytes); - let terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) + let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) + .map_err(|e| std::io::Error::other(e.to_string()))?; + terminal + .enable_kitty_graphics() .map_err(|e| std::io::Error::other(e.to_string()))?; let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); @@ -663,23 +666,28 @@ impl PaneRuntime { } // --- Resize task --- - let (resize_tx, mut resize_rx) = watch::channel::<(u16, u16)>((rows, cols)); + let (resize_tx, mut resize_rx) = watch::channel::<(u16, u16, u32, u32)>((rows, cols, 0, 0)); { let master = pair.master; tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Handle::current(); - let mut last_size = (rows, cols); + let mut last_size = (rows, cols, 0, 0); while rt.block_on(resize_rx.changed()).is_ok() { - let (rows, cols) = *resize_rx.borrow_and_update(); - if (rows, cols) == last_size { + let (rows, cols, cell_width_px, cell_height_px) = + *resize_rx.borrow_and_update(); + if (rows, cols, cell_width_px, cell_height_px) == last_size { continue; } - last_size = (rows, cols); + last_size = (rows, cols, cell_width_px, cell_height_px); if let Err(e) = master.resize(PtySize { rows, cols, - pixel_width: 0, - pixel_height: 0, + pixel_width: (cols as u32) + .saturating_mul(cell_width_px) + .min(u16::MAX as u32) as u16, + pixel_height: (rows as u32) + .saturating_mul(cell_height_px) + .min(u16::MAX as u32) as u16, }) { warn!(pane = pane_id.raw(), err = %e, rows, cols, "pty resize failed"); } @@ -691,7 +699,7 @@ impl PaneRuntime { terminal, sender: input_tx, resize_tx, - current_size: Cell::new((rows, cols)), + current_size: Cell::new((rows, cols, 0, 0)), child_pid, kitty_keyboard_flags, detect_reset_notify, @@ -712,19 +720,22 @@ impl PaneRuntime { #[cfg(test)] pub(crate) fn current_size(&self) -> (u16, u16) { - self.current_size.get() + let (rows, cols, _, _) = self.current_size.get(); + (rows, cols) } /// Resize if the dimensions actually changed. - pub fn resize(&self, rows: u16, cols: u16) { + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { let rows = rows.max(2); let cols = cols.max(4); - if self.current_size.get() == (rows, cols) { + let size = (rows, cols, cell_width_px, cell_height_px); + if self.current_size.get() == size { return; } - self.current_size.set((rows, cols)); - self.terminal.resize(rows, cols); - let _ = self.resize_tx.send((rows, cols)); + self.current_size.set(size); + self.terminal + .resize(rows, cols, cell_width_px, cell_height_px); + let _ = self.resize_tx.send(size); } /// Scroll up by N lines (into scrollback history). @@ -806,6 +817,10 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } + pub fn kitty_image_placements(&self) -> Vec { + self.terminal.kitty_image_placements() + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { let fallback = crate::input::KeyboardProtocol::from_kitty_flags( self.kitty_keyboard_flags.load(Ordering::Relaxed), @@ -957,7 +972,7 @@ impl PaneRuntime { channel_capacity: usize, ) -> (Self, mpsc::Receiver) { let (tx, rx) = mpsc::channel(channel_capacity); - let (resize_tx, _resize_rx) = watch::channel((rows, cols)); + let (resize_tx, _resize_rx) = watch::channel((rows, cols, 0, 0)); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap(); terminal.write(bytes); @@ -969,7 +984,7 @@ impl PaneRuntime { )), sender: tx, resize_tx, - current_size: Cell::new((rows, cols)), + current_size: Cell::new((rows, cols, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), detect_reset_notify: Arc::new(Notify::new()), @@ -1040,7 +1055,7 @@ mod tests { #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); - let (resize_tx, _resize_rx) = watch::channel((80, 24)); + let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0)); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal .mode_set(crate::ghostty::MODE_FOCUS_EVENT, true) @@ -1051,7 +1066,7 @@ mod tests { )), sender: tx, resize_tx, - current_size: Cell::new((80, 24)), + current_size: Cell::new((80, 24, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), detect_reset_notify: Arc::new(Notify::new()), @@ -1066,7 +1081,7 @@ mod tests { #[tokio::test] async fn focus_events_are_suppressed_when_disabled() { let (tx, mut rx) = mpsc::channel(4); - let (resize_tx, _resize_rx) = watch::channel((80, 24)); + let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0)); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let runtime = PaneRuntime { terminal: Arc::new(PaneTerminal::new( @@ -1074,7 +1089,7 @@ mod tests { )), sender: tx, resize_tx, - current_size: Cell::new((80, 24)), + current_size: Cell::new((80, 24, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), detect_reset_notify: Arc::new(Notify::new()), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5c5ad211..f45d9df7 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use ratatui::style::{Color, Modifier, Style}; use ratatui::{layout::Rect, Frame}; use tokio::sync::mpsc; -use tracing::{debug, error}; +use tracing::{debug, error, info, warn}; use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; @@ -97,8 +97,9 @@ impl PaneTerminal { .process_pty_bytes(pane_id, shell_pid, bytes, response_writer) } - pub fn resize(&self, rows: u16, cols: u16) { - self.ghostty.resize(rows, cols); + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { + self.ghostty + .resize(rows, cols, cell_width_px, cell_height_px); } pub fn scroll_up(&self, lines: usize) { @@ -169,6 +170,10 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } + pub fn kitty_image_placements(&self) -> Vec { + self.ghostty.kitty_image_placements() + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.ghostty.apply_host_terminal_theme(theme); } @@ -353,6 +358,20 @@ impl GhosttyPaneTerminal { } core.terminal.write(filtered_bytes.as_ref()); + if contains_kitty_graphics_sequence(filtered_bytes.as_ref()) { + match core.terminal.kitty_image_placements() { + Ok(placements) => info!( + pane = pane_id.raw(), + placements = placements.len(), + "processed kitty graphics sequence" + ), + Err(err) => warn!( + pane = pane_id.raw(), + err = %err, + "failed to query kitty graphics after sequence" + ), + } + } if let Ok(mut key_encoder) = self.key_encoder.lock() { key_encoder.set_from_terminal(&core.terminal); } @@ -367,9 +386,11 @@ impl GhosttyPaneTerminal { } } - pub fn resize(&self, rows: u16, cols: u16) { + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { if let Ok(mut core) = self.core.lock() { - let _ = core.terminal.resize(cols, rows); + let _ = core + .terminal + .resize(cols, rows, cell_width_px, cell_height_px); } } @@ -621,6 +642,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub fn kitty_image_placements(&self) -> Vec { + self.core + .lock() + .ok() + .and_then(|core| core.terminal.kitty_image_placements().ok()) + .unwrap_or_default() + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { let Ok(mut core) = self.core.lock() else { return; @@ -1110,6 +1139,10 @@ fn recent_text_from_rows(rows: &[String], lines: usize) -> String { } } +fn contains_kitty_graphics_sequence(bytes: &[u8]) -> bool { + bytes.windows(3).any(|window| window == b"\x1b_G") +} + fn should_probe_host_terminal_theme_restore(core: &GhosttyPaneCore) -> bool { if core.transient_default_color_owner_pgid.is_none() || core.host_terminal_theme.is_empty() { return false; @@ -1573,7 +1606,7 @@ mod tests { assert!(!pane.visible_text().trim().is_empty()); for (rows, cols) in [(4, 10), (4, 7), (6, 18), (3, 9), (5, 12)] { - pane.resize(rows, cols); + pane.resize(rows, cols, 0, 0); let metrics = pane.scroll_metrics().expect("scroll metrics after resize"); assert_eq!(metrics.viewport_rows, rows as usize); diff --git a/src/ui.rs b/src/ui.rs index b348866d..2e1acc56 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -86,7 +86,20 @@ pub(super) fn spinner_frame(tick: u32) -> &'static str { /// Compute view geometry and reconcile pane sizes. /// Called before render to separate mutation from drawing. pub fn compute_view(app: &mut AppState, area: Rect) { - compute_view_internal(app, area, true); + compute_view_internal( + app, + area, + true, + crate::kitty_graphics::HostCellSize::default(), + ); +} + +pub fn compute_view_with_cell_size( + app: &mut AppState, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) { + compute_view_internal(app, area, true, cell_size); } /// Compute view geometry for a client-sized render without resizing pane runtimes. @@ -95,23 +108,37 @@ pub fn compute_view(app: &mut AppState, area: Rect) { /// own frame size while the shared pane runtimes stay pinned to the foreground /// client. pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) { - compute_view_internal(app, area, false); + compute_view_internal( + app, + area, + false, + crate::kitty_graphics::HostCellSize::default(), + ); } -fn resize_background_tab_panes_to_terminal_area(app: &AppState, terminal_area: Rect) { +fn resize_background_tab_panes_to_terminal_area( + app: &AppState, + terminal_area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) { for (ws_idx, ws) in app.workspaces.iter().enumerate() { for (tab_idx, tab) in ws.tabs.iter().enumerate() { if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { continue; } - resize_tab_panes(tab, terminal_area); + resize_tab_panes(tab, terminal_area, cell_size); } } } -fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { +fn compute_view_internal( + app: &mut AppState, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) { if is_mobile_width(area) { - compute_mobile_view(app, area, resize_panes); + compute_mobile_view(app, area, resize_panes, cell_size); return; } @@ -171,9 +198,9 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); + let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); if resize_panes { - resize_background_tab_panes_to_terminal_area(app, terminal_area); + resize_background_tab_panes_to_terminal_area(app, terminal_area, cell_size); } let toast_hit_area = app @@ -200,7 +227,12 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { }; } -fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) { +fn compute_mobile_view( + app: &mut AppState, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) { let header_h = area.height.min(2); let (header_rect, terminal_area) = if area.height > header_h { let [header_rect, terminal_area] = @@ -222,9 +254,9 @@ fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) { .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); + let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); if resize_panes { - resize_background_tab_panes_to_terminal_area(app, terminal_area); + resize_background_tab_panes_to_terminal_area(app, terminal_area, cell_size); } let header_hits = compute_mobile_header_hit_areas(app, header_rect); diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 72a5c30c..289d5fab 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -75,14 +75,23 @@ fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option< } /// Resize every visible runtime in a tab to the geometry it would receive if the tab were selected. -pub(super) fn resize_tab_panes(tab: &crate::workspace::Tab, area: Rect) { +pub(super) fn resize_tab_panes( + tab: &crate::workspace::Tab, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) { let multi_pane = tab.layout.pane_count() > 1; if tab.zoomed { let focused_id = tab.layout.focused(); if let Some(rt) = tab.runtimes.get(&focused_id) { let inner_rect = stable_terminal_inner_rect(area); - rt.resize(inner_rect.height, inner_rect.width); + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); } return; } @@ -96,13 +105,23 @@ pub(super) fn resize_tab_panes(tab: &crate::workspace::Tab, area: Rect) { if let Some(rt) = tab.runtimes.get(&info.id) { let inner_rect = stable_terminal_inner_rect(pane_inner); - rt.resize(inner_rect.height, inner_rect.width); + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); } } } /// Compute pane layout info and optionally resize pane runtimes to match. -pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> Vec { +pub(super) fn compute_pane_infos( + app: &AppState, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Vec { let Some(ws_idx) = app.active else { return Vec::new(); }; @@ -120,7 +139,12 @@ pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) if let Some(rt) = ws.runtimes.get(&focused_id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area); if resize_panes { - rt.resize(inner_rect.height, inner_rect.width); + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); } } return vec![PaneInfo { @@ -154,7 +178,12 @@ pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) if let Some(rt) = ws.runtimes.get(&info.id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes { - rt.resize(inner_rect.height, inner_rect.width); + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); } } @@ -358,7 +387,12 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); - let infos = compute_pane_infos(&app, area, false); + let infos = compute_pane_infos( + &app, + area, + false, + crate::kitty_graphics::HostCellSize::default(), + ); let info = &infos[0]; assert_eq!(info.rect, area); @@ -380,7 +414,12 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); - let infos = compute_pane_infos(&app, area, false); + let infos = compute_pane_infos( + &app, + area, + false, + crate::kitty_graphics::HostCellSize::default(), + ); let info = &infos[0]; assert_eq!(info.rect, area); @@ -401,7 +440,12 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 4, 8); - let infos = compute_pane_infos(&app, area, false); + let infos = compute_pane_infos( + &app, + area, + false, + crate::kitty_graphics::HostCellSize::default(), + ); let info = &infos[0]; assert_eq!(info.rect, area); @@ -427,7 +471,12 @@ mod tests { app.active = Some(0); let area = Rect::new(10, 3, 40, 8); - let infos = compute_pane_infos(&app, area, false); + let infos = compute_pane_infos( + &app, + area, + false, + crate::kitty_graphics::HostCellSize::default(), + ); let info = &infos[0]; assert_eq!(info.rect, area);