feat: render kitty graphics in local panes

This commit is contained in:
Ogulcan Celik 2026-05-13 00:54:24 +03:00
parent 83cc6ef012
commit fb9aff5408
12 changed files with 1510 additions and 54 deletions

64
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

53
KITTY_GRAPHICS_PRD.md Normal file
View File

@ -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.

View File

@ -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;

View File

@ -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;
}

View File

@ -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 =
@ -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");

595
src/kitty_graphics.rs Normal file
View File

@ -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\\");
}
}

View File

@ -29,6 +29,7 @@ mod ghostty;
mod input;
mod integration;
mod ipc;
mod kitty_graphics;
mod layout;
mod logging;
mod pane;

View File

@ -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()),

View File

@ -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);

View File

@ -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);

View File

@ -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);