feat: render kitty graphics in local panes
This commit is contained in:
parent
83cc6ef012
commit
fb9aff5408
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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\\");
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ mod ghostty;
|
|||
mod input;
|
||||
mod integration;
|
||||
mod ipc;
|
||||
mod kitty_graphics;
|
||||
mod layout;
|
||||
mod logging;
|
||||
mod pane;
|
||||
|
|
|
|||
61
src/pane.rs
61
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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
54
src/ui.rs
54
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue