aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 00:54:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 14:15:26 +0300
commitfb9aff54087aa8a606e9260a689986d2bd76f5fa (patch)
tree1e0e2b6449c49795de5878ec43128b8e3b58d497
parent83cc6ef01235f6353bbc73f49aaa73a82ecee402 (diff)
feat: render kitty graphics in local panes
-rw-r--r--Cargo.lock64
-rw-r--r--Cargo.toml1
-rw-r--r--KITTY_GRAPHICS_PRD.md53
-rw-r--r--src/app/mod.rs28
-rw-r--r--src/ghostty/bindings.rs159
-rw-r--r--src/ghostty/mod.rs434
-rw-r--r--src/kitty_graphics.rs595
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs61
-rw-r--r--src/pane/terminal.rs45
-rw-r--r--src/ui.rs54
-rw-r--r--src/ui/panes.rs69
12 files changed, 1510 insertions, 54 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e0a310dd..bf6ec176 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,6 +3,12 @@
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -187,6 +193,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -394,6 +409,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -417,6 +441,16 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -505,6 +539,7 @@ dependencies = [
"crossterm",
"ctrlc",
"libc",
+ "png",
"portable-pty",
"ratatui",
"regex",
@@ -721,6 +756,16 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -978,6 +1023,19 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1365,6 +1423,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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<Self> {
+ 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<crate::raw_input::RawInputEvent>>,
) -> Option<crate::raw_input::RawInputEvent> {
@@ -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<u8>,
+ 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<u8>,
+}
+
+fn decode_png_rgba(bytes: &[u8]) -> Option<DecodedPng> {
+ 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<Vec<u8>, 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<F>(&mut self, callback: F) -> Result<(), Error>
@@ -651,11 +836,145 @@ impl Terminal {
Ok(out)
}
+ pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, 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<Option<KittyImagePlacement>, 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::<ffi::GhosttyKittyGraphicsPlacementRenderInfo>(),
+ ..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<Vec<u32>, Error>
Ok(buffer)
}
+fn kitty_placement_u32(
+ iterator: ffi::GhosttyKittyGraphicsPlacementIterator,
+ data: ffi::GhosttyKittyGraphicsPlacementData,
+) -> Result<u32, Error> {
+ 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<i32, Error> {
+ 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<u32, Error> {
+ 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<KittyImageFormat, Error> {
+ 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<ffi::GhosttyKittyImageCompression, Error> {
+ 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<Vec<u8>, 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<Option<String>, Error> {
let mut required = 0usize;
let result =
@@ -1394,6 +1805,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");
assert_eq!(encode_focus(FocusEvent::Lost).unwrap(), b"\x1b[O");
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<Mutex<HashMap<u32, ImageSignature>>> = OnceLock::new();
+static HOST_PLACEMENTS: OnceLock<Mutex<HashMap<(u32, u32), PlacementSignature>>> = 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::<Vec<_>>() {
+ 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<HostPlacement> {
+ 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<u8>, id: u32) {
+ let _ = write!(out, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\");
+}
+
+fn encode_delete_placement(out: &mut Vec<u8>, 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<u8>,
+ 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<u8>,
+ 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<u8>, 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<PaneTerminal>,
sender: mpsc::Sender<Bytes>,
- 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<AtomicU32>,
kitty_keyboard_flags: Arc<AtomicU16>,
detect_reset_notify: Arc<Notify>,
@@ -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<crate::ghostty::KittyImagePlacement> {
+ 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<Bytes>) {
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<crate::ghostty::KittyImagePlacement> {
+ 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<crate::ghostty::KittyImagePlacement> {
+ 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<PaneInfo> {
+pub(super) fn compute_pane_infos(
+ app: &AppState,
+ area: Rect,
+ resize_panes: bool,
+ cell_size: crate::kitty_graphics::HostCellSize,
+) -> Vec<PaneInfo> {
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);