aboutsummaryrefslogtreecommitdiff
path: root/crates/ghostty-vt/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/ghostty-vt/src/lib.rs')
-rw-r--r--crates/ghostty-vt/src/lib.rs4796
1 files changed, 4796 insertions, 0 deletions
diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs
new file mode 100644
index 00000000..ef62ef1e
--- /dev/null
+++ b/crates/ghostty-vt/src/lib.rs
@@ -0,0 +1,4796 @@
+#![allow(dead_code)]
+
+#[allow(
+ dead_code,
+ non_camel_case_types,
+ non_snake_case,
+ non_upper_case_globals,
+ clippy::all,
+ rustdoc::all
+)]
+pub mod bindings;
+
+use std::cell::Cell;
+use std::collections::hash_map::DefaultHasher;
+use std::collections::{HashMap, HashSet};
+use std::ffi::c_void;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::marker::PhantomData;
+use std::mem;
+use std::ops::RangeInclusive;
+use std::os::raw::c_char;
+use std::ptr;
+use std::slice;
+use std::sync::{Arc, Mutex, Once, OnceLock};
+
+use crate::pane_graphics_files::OwnedExport;
+#[cfg(target_os = "linux")]
+mod native_image_sources;
+mod native_source;
+pub mod pane_graphics_files;
+
+/// Terminfo entry the terminal emulates; child processes should see it as TERM.
+pub const TERM: &str = "xterm-256color";
+
+pub use bindings as ffi;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Error(ffi::GhosttyResult);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum FormatterFormat {
+ Plain,
+ Vt,
+}
+
+impl FormatterFormat {
+ fn as_raw(self) -> ffi::GhosttyFormatterFormat {
+ match self {
+ Self::Plain => ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN,
+ Self::Vt => ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_VT,
+ }
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "ghostty error {}", self.0)
+ }
+}
+
+impl std::error::Error for Error {}
+
+trait GhosttyResultExt {
+ fn into_result(self) -> Result<(), Error>;
+}
+
+impl GhosttyResultExt for ffi::GhosttyResult {
+ fn into_result(self) -> Result<(), Error> {
+ if self == ffi::GhosttyResult_GHOSTTY_SUCCESS {
+ Ok(())
+ } else {
+ Err(Error(self))
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dirty {
+ Clean,
+ Partial,
+ Full,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalCompressionResult {
+ Unsupported,
+ Pending,
+ Complete,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RowSelection {
+ pub start_x: u16,
+ pub end_x: u16,
+}
+
+impl RowSelection {
+ pub fn range(self) -> RangeInclusive<u16> {
+ self.start_x..=self.end_x
+ }
+}
+
+impl Dirty {
+ fn from_raw(value: ffi::GhosttyRenderStateDirty) -> Self {
+ match value {
+ ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FALSE => Self::Clean,
+ ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_PARTIAL => Self::Partial,
+ ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FULL => Self::Full,
+ _ => Self::Full,
+ }
+ }
+
+ fn as_raw(self) -> ffi::GhosttyRenderStateDirty {
+ match self {
+ Self::Clean => ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FALSE,
+ Self::Partial => ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_PARTIAL,
+ Self::Full => ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FULL,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FocusEvent {
+ Gained,
+ Lost,
+}
+
+impl FocusEvent {
+ fn as_raw(self) -> ffi::GhosttyFocusEvent {
+ match self {
+ Self::Gained => ffi::GhosttyFocusEvent_GHOSTTY_FOCUS_GAINED,
+ Self::Lost => ffi::GhosttyFocusEvent_GHOSTTY_FOCUS_LOST,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ColorScheme {
+ Light,
+ Dark,
+}
+
+impl ColorScheme {
+ fn as_raw(self) -> ffi::GhosttyColorScheme {
+ match self {
+ Self::Light => ffi::GhosttyColorScheme_GHOSTTY_COLOR_SCHEME_LIGHT,
+ Self::Dark => ffi::GhosttyColorScheme_GHOSTTY_COLOR_SCHEME_DARK,
+ }
+ }
+}
+
+pub const MOD_SHIFT: u16 = ffi::GHOSTTY_MODS_SHIFT as u16;
+pub const MOD_CTRL: u16 = ffi::GHOSTTY_MODS_CTRL as u16;
+pub const MOD_ALT: u16 = ffi::GHOSTTY_MODS_ALT as u16;
+pub const MOD_SUPER: u16 = ffi::GHOSTTY_MODS_SUPER as u16;
+
+pub const KEY_ENTER: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_ENTER;
+pub const KEY_UP: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP;
+pub const KEY_DOWN: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN;
+pub const KEY_LEFT: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT;
+pub const KEY_RIGHT: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT;
+pub const KEY_A: ffi::GhosttyKey = ffi::GhosttyKey_GHOSTTY_KEY_A;
+
+pub const MOUSE_ACTION_PRESS: ffi::GhosttyMouseAction =
+ ffi::GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_PRESS;
+pub const MOUSE_ACTION_RELEASE: ffi::GhosttyMouseAction =
+ ffi::GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_RELEASE;
+pub const MOUSE_ACTION_MOTION: ffi::GhosttyMouseAction =
+ ffi::GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_MOTION;
+pub const MOUSE_BUTTON_LEFT: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_LEFT;
+pub const MOUSE_BUTTON_RIGHT: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_RIGHT;
+pub const MOUSE_BUTTON_MIDDLE: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_MIDDLE;
+pub const MOUSE_BUTTON_WHEEL_UP: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_FOUR;
+pub const MOUSE_BUTTON_WHEEL_DOWN: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_FIVE;
+pub const MOUSE_BUTTON_WHEEL_LEFT: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SIX;
+pub const MOUSE_BUTTON_WHEEL_RIGHT: ffi::GhosttyMouseButton =
+ ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SEVEN;
+pub const MOUSE_FORMAT_SGR: ffi::GhosttyMouseFormat =
+ ffi::GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR;
+pub const MOUSE_FORMAT_SGR_PIXELS: ffi::GhosttyMouseFormat =
+ ffi::GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR_PIXELS;
+
+pub const MODE_APPLICATION_CURSOR_KEYS: u16 = 1;
+pub const MODE_FOCUS_EVENT: u16 = 1004;
+pub const MODE_MOUSE_UTF8: u16 = 1005;
+pub const MODE_MOUSE_SGR: u16 = 1006;
+pub const MODE_MOUSE_ALTERNATE_SCROLL: u16 = 1007;
+pub const MODE_MOUSE_SGR_PIXELS: u16 = 1016;
+pub const MODE_BRACKETED_PASTE: u16 = 2004;
+pub const MODE_SYNCHRONIZED_OUTPUT: u16 = 2026;
+pub const MODE_GRAPHEME_CLUSTER: u16 = 2027;
+pub const MODE_COLOR_SCHEME_REPORT: u16 = 2031;
+// These are documented in vendor/libghostty-vt/include/ghostty/vt/terminal.h,
+// but the generated bindings do not currently expose named constants for them.
+const TERMINAL_DATA_COLOR_FOREGROUND: ffi::GhosttyTerminalData = 18;
+const TERMINAL_DATA_COLOR_CURSOR: ffi::GhosttyTerminalData = 20;
+
+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;
+pub const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE;
+// The vendored C headers expose these placement fields, but the checked-in
+// generated bindings predate the names. Keep the explicit values aligned with
+// vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h.
+const KITTY_PLACEMENT_DATA_IS_VIRTUAL: ffi::GhosttyKittyGraphicsPlacementData = 3;
+const KITTY_PLACEMENT_DATA_COLUMNS: ffi::GhosttyKittyGraphicsPlacementData = 10;
+const KITTY_PLACEMENT_DATA_ROWS: ffi::GhosttyKittyGraphicsPlacementData = 11;
+
+static INSTALL_PNG_DECODER: Once = Once::new();
+static KITTY_PLACEHOLDER_DIACRITICS: OnceLock<HashMap<u32, u32>> = OnceLock::new();
+
+#[derive(Debug, Clone, Copy, Hash, 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_len: usize,
+ pub data_fingerprint: u64,
+ pub data: Vec<u8>,
+ pub source_file: Option<Arc<OwnedExport>>,
+ pub render: KittyPlacementRenderInfo,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct KittyImageDescriptor {
+ pub image_id: u32,
+ pub placement_id: u32,
+ pub image_width: u32,
+ pub image_height: u32,
+ pub format: KittyImageFormat,
+ pub data_len: usize,
+ pub data_fingerprint: u64,
+ pub source_file: bool,
+}
+
+type KittyPlacementPayload = (KittyImageDescriptor, Vec<u8>, Option<Arc<OwnedExport>>);
+
+#[derive(Debug, Clone, Copy)]
+struct KittyImageFingerprintEntry {
+ generation: u64,
+ fingerprint: u64,
+}
+
+#[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)]
+struct KittyVirtualPlacementSpec {
+ image_id: u32,
+ placement_id: u32,
+ columns: u32,
+ rows: u32,
+ z: i32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualCell {
+ x: u16,
+ y: u16,
+ image_id_low: u32,
+ image_id_high: Option<u32>,
+ placement_id: Option<u32>,
+ row: Option<u32>,
+ col: Option<u32>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualRun {
+ x: u16,
+ y: u16,
+ image_id_low: u32,
+ image_id_high: Option<u32>,
+ placement_id: Option<u32>,
+ row: u32,
+ col: u32,
+ width: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualPlacementGeometry {
+ x_offset: u32,
+ y_offset: u32,
+ render: KittyPlacementRenderInfo,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CursorVisualStyle {
+ Bar,
+ Block,
+ Underline,
+ BlockHollow,
+}
+
+impl CursorVisualStyle {
+ fn from_raw(value: ffi::GhosttyRenderStateCursorVisualStyle) -> Self {
+ match value {
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK => {
+ Self::Block
+ }
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE => {
+ Self::Underline
+ }
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW => {
+ Self::BlockHollow
+ }
+ _ => Self::Bar,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ActiveScreen {
+ Primary,
+ Alternate,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TerminalScrollbar {
+ pub total: usize,
+ pub offset: usize,
+ pub len: usize,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CursorViewport {
+ pub x: u16,
+ pub y: u16,
+ pub wide_tail: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RenderCursor {
+ pub viewport: Option<CursorViewport>,
+ pub visible: bool,
+ pub blinking: bool,
+ pub visual_style: CursorVisualStyle,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct RgbColor {
+ pub r: u8,
+ pub g: u8,
+ pub b: u8,
+}
+
+impl From<ffi::GhosttyColorRgb> for RgbColor {
+ fn from(value: ffi::GhosttyColorRgb) -> Self {
+ Self {
+ r: value.r,
+ g: value.g,
+ b: value.b,
+ }
+ }
+}
+
+pub fn default_palette() -> [RgbColor; 256] {
+ let mut palette = [ffi::GhosttyColorRgb::default(); 256];
+ unsafe { ffi::ghostty_color_palette_default(palette.as_mut_ptr()) };
+ palette.map(Into::into)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CellColor {
+ Palette(u8),
+ Rgb(RgbColor),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct CellStyle {
+ pub fg_color: Option<CellColor>,
+ pub bg_color: Option<CellColor>,
+ pub underline_color: Option<CellColor>,
+ pub bold: bool,
+ pub italic: bool,
+ pub faint: bool,
+ pub blink: bool,
+ pub inverse: bool,
+ pub invisible: bool,
+ pub strikethrough: bool,
+ pub overline: bool,
+ pub underline: u8,
+ pub underlined: bool,
+}
+
+impl From<ffi::GhosttyStyle> for CellStyle {
+ fn from(value: ffi::GhosttyStyle) -> Self {
+ Self {
+ fg_color: cell_color_from_style_color(value.fg_color),
+ bg_color: cell_color_from_style_color(value.bg_color),
+ underline_color: cell_color_from_style_color(value.underline_color),
+ bold: value.bold,
+ italic: value.italic,
+ faint: value.faint,
+ blink: value.blink,
+ inverse: value.inverse,
+ invisible: value.invisible,
+ strikethrough: value.strikethrough,
+ overline: value.overline,
+ underline: normalize_underline_style(value.underline),
+ underlined: value.underline != 0,
+ }
+ }
+}
+
+fn normalize_underline_style(value: std::os::raw::c_int) -> u8 {
+ match value {
+ 0..=5 => value as u8,
+ _ => 1,
+ }
+}
+
+fn cell_color_from_style_color(color: ffi::GhosttyStyleColor) -> Option<CellColor> {
+ match color.tag {
+ ffi::GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_PALETTE => {
+ // SAFETY: Ghostty's tagged union stores `palette` when the tag is PALETTE.
+ Some(CellColor::Palette(unsafe { color.value.palette }))
+ }
+ ffi::GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_RGB => {
+ // SAFETY: Ghostty's tagged union stores `rgb` when the tag is RGB.
+ Some(CellColor::Rgb(unsafe { color.value.rgb }.into()))
+ }
+ _ => None,
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RenderColors {
+ pub background: RgbColor,
+ pub foreground: RgbColor,
+ pub palette: [RgbColor; 256],
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CellWide {
+ Narrow,
+ Wide,
+ SpacerTail,
+ SpacerHead,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScreenTextCell {
+ pub wide: CellWide,
+ pub graphemes: Vec<u32>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScreenTextRow {
+ pub cells: Vec<ScreenTextCell>,
+ pub soft_wrapped: bool,
+ pub wrap_continuation: bool,
+}
+
+impl CellWide {
+ fn from_raw(value: ffi::GhosttyCellWide) -> Self {
+ match value {
+ ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW => Self::Narrow,
+ ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_WIDE => Self::Wide,
+ ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_SPACER_TAIL => Self::SpacerTail,
+ ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_SPACER_HEAD => Self::SpacerHead,
+ _ => Self::Narrow,
+ }
+ }
+}
+
+type WritePtyCallback = dyn FnMut(&[u8]) + Send;
+
+const MAX_CLIPBOARD_BYTES: usize = 192 * 1024;
+
+#[derive(Default)]
+struct TerminalCallbackState {
+ write_pty: Option<Box<WritePtyCallback>>,
+ bell_count: u16,
+ pwd_changes: Vec<Vec<u8>>,
+ clipboard_writes: Vec<Vec<u8>>,
+ size_report: ffi::GhosttySizeReportSize,
+ color_scheme: Option<ColorScheme>,
+}
+
+unsafe extern "C" fn bell_trampoline(_terminal: ffi::GhosttyTerminal, userdata: *mut c_void) {
+ if userdata.is_null() {
+ return;
+ }
+ // SAFETY: userdata is the TerminalCallbackState installed with this terminal.
+ let state = unsafe { &mut *userdata.cast::<TerminalCallbackState>() };
+ state.bell_count = state.bell_count.saturating_add(1);
+}
+
+unsafe extern "C" fn color_scheme_trampoline(
+ _terminal: ffi::GhosttyTerminal,
+ userdata: *mut c_void,
+ out_scheme: *mut ffi::GhosttyColorScheme,
+) -> bool {
+ if userdata.is_null() || out_scheme.is_null() {
+ return false;
+ }
+ let state = unsafe { &*userdata.cast::<TerminalCallbackState>() };
+ let Some(color_scheme) = state.color_scheme else {
+ return false;
+ };
+ unsafe {
+ out_scheme.write(color_scheme.as_raw());
+ }
+ true
+}
+
+unsafe extern "C" fn size_trampoline(
+ _terminal: ffi::GhosttyTerminal,
+ userdata: *mut c_void,
+ out_size: *mut ffi::GhosttySizeReportSize,
+) -> bool {
+ if userdata.is_null() || out_size.is_null() {
+ return false;
+ }
+ let state = unsafe { &*userdata.cast::<TerminalCallbackState>() };
+ let size = state.size_report;
+ if size.rows == 0 || size.columns == 0 || size.cell_width == 0 || size.cell_height == 0 {
+ return false;
+ }
+ unsafe {
+ out_size.write(size);
+ }
+ true
+}
+
+unsafe extern "C" fn write_pty_trampoline(
+ _terminal: ffi::GhosttyTerminal,
+ userdata: *mut c_void,
+ data: *const u8,
+ len: usize,
+) {
+ if userdata.is_null() || (data.is_null() && len != 0) {
+ return;
+ }
+ let state = unsafe { &mut *(userdata.cast::<TerminalCallbackState>()) };
+ let Some(callback) = state.write_pty.as_mut() else {
+ return;
+ };
+ let bytes = if len == 0 {
+ &[]
+ } else {
+ unsafe { slice::from_raw_parts(data, len) }
+ };
+ callback(bytes);
+}
+
+unsafe extern "C" fn clipboard_write_trampoline(
+ _terminal: ffi::GhosttyTerminal,
+ userdata: *mut c_void,
+ write: *const ffi::GhosttyClipboardWrite,
+) {
+ if write.is_null() || unsafe { (*write).size } < mem::size_of::<ffi::GhosttyClipboardWrite>() {
+ return;
+ }
+ // The reply and request are borrowed only until this callback returns.
+ let Some(reply) = (unsafe { (*write).reply }) else {
+ return;
+ };
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ // SAFETY: libghostty-vt owns these values for the synchronous callback.
+ unsafe { capture_clipboard_write(userdata, write) }
+ }))
+ .unwrap_or(ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA);
+ let response = ffi::GhosttyClipboardWriteReply {
+ size: mem::size_of::<ffi::GhosttyClipboardWriteReply>(),
+ result,
+ remember: false,
+ };
+ unsafe { reply(write, &response) };
+}
+
+unsafe fn capture_clipboard_write(
+ userdata: *mut c_void,
+ write: *const ffi::GhosttyClipboardWrite,
+) -> ffi::GhosttyClipboardWriteResult {
+ if userdata.is_null() || write.is_null() {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ }
+
+ let required_size = std::mem::offset_of!(ffi::GhosttyClipboardWrite, contents_len)
+ + std::mem::size_of::<usize>();
+ // SAFETY: size is the leading field of the live request.
+ if unsafe { (*write).size } < required_size {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ }
+ // SAFETY: the size check covers every field accessed below.
+ let request = unsafe { &*write };
+ if request.location != ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
+ }
+
+ // SAFETY: userdata is the TerminalCallbackState installed with this terminal.
+ let state = unsafe { &mut *userdata.cast::<TerminalCallbackState>() };
+ if request.contents_len == 0 {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
+ }
+ if request.contents_len != 1 {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
+ }
+ if request.contents.is_null() {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ }
+
+ // SAFETY: libghostty-vt keeps the single content and its strings alive for the callback.
+ let content = unsafe { &*request.contents };
+ // SAFETY: the MIME string is borrowed from the live callback request.
+ let Some(mime) = (unsafe { borrowed_bytes(content.mime) }) else {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ };
+ let is_text = std::str::from_utf8(mime)
+ .ok()
+ .and_then(|mime| mime.split(';').next())
+ .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain"));
+ if !is_text {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
+ }
+
+ // SAFETY: the data string is borrowed from the live callback request.
+ let Some(bytes) = (unsafe { borrowed_bytes(content.data) }) else {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ };
+ if bytes.is_empty() {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
+ }
+ if bytes.len() > MAX_CLIPBOARD_BYTES {
+ return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+ }
+ state.clipboard_writes.push(bytes.to_vec());
+ ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS
+}
+
+unsafe fn borrowed_bytes<'a>(value: ffi::GhosttyString) -> Option<&'a [u8]> {
+ if value.len == 0 {
+ Some(&[])
+ } else if value.ptr.is_null() {
+ None
+ } else {
+ // SAFETY: the callback contract keeps pointer and length valid until return.
+ Some(unsafe { slice::from_raw_parts(value.ptr, value.len) })
+ }
+}
+
+unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) {
+ if terminal.is_null() || userdata.is_null() {
+ return;
+ }
+ let mut pwd = ffi::GhosttyString::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_get(
+ terminal,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_PWD,
+ (&mut pwd as *mut ffi::GhosttyString).cast(),
+ )
+ };
+ if result != ffi::GhosttyResult_GHOSTTY_SUCCESS || (pwd.ptr.is_null() && pwd.len != 0) {
+ return;
+ }
+ let bytes = if pwd.len == 0 {
+ Vec::new()
+ } else {
+ unsafe { slice::from_raw_parts(pwd.ptr, pwd.len) }.to_vec()
+ };
+ let state = unsafe { &mut *(userdata.cast::<TerminalCallbackState>()) };
+ state.pwd_changes.push(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(),
+ );
+ });
+}
+
+#[cfg(test)]
+thread_local! {
+ static PNG_DECODE_CALLS: Cell<usize> = const { Cell::new(0) };
+}
+
+#[cfg(test)]
+mod native_source_tests;
+#[cfg(test)]
+mod png_forward_tests;
+
+unsafe extern "C" fn decode_png_trampoline(
+ _userdata: *mut c_void,
+ allocator: *const ffi::GhosttyAllocator,
+ 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) };
+ #[cfg(test)]
+ PNG_DECODE_CALLS.with(|calls| calls.set(calls.get() + 1));
+ 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 unicode_codepoint_width(codepoint: u32) -> u8 {
+ unsafe { ffi::ghostty_unicode_codepoint_width(codepoint) }
+}
+
+pub fn unicode_grapheme_width(codepoints: &[u32]) -> (usize, u8) {
+ let mut width = 0u8;
+ let consumed = unsafe {
+ ffi::ghostty_unicode_grapheme_width(codepoints.as_ptr(), codepoints.len(), &mut width)
+ };
+ (consumed, width)
+}
+
+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.
+ let result =
+ unsafe { ffi::ghostty_focus_encode(event.as_raw(), ptr::null_mut(), 0, &mut required) };
+ if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
+ result.into_result()?;
+ }
+
+ let mut buffer = vec![0u8; required];
+ // SAFETY: buffer is allocated for required size; function writes at most that many bytes.
+ unsafe {
+ ffi::ghostty_focus_encode(
+ event.as_raw(),
+ buffer.as_mut_ptr().cast(),
+ buffer.len(),
+ &mut required,
+ )
+ .into_result()?;
+ }
+ buffer.truncate(required);
+ Ok(buffer)
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum LinkTarget {
+ Uri(String),
+ Text { text: String, clicked_byte: usize },
+}
+
+/// Inclusive display-cell columns on the current viewport.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct LinkRegion {
+ pub row: u16,
+ pub start_col: u16,
+ pub end_col: u16,
+}
+
+pub struct Terminal {
+ raw: ffi::GhosttyTerminal,
+ max_scrollback: usize,
+ #[cfg(windows)]
+ tracked_row: ffi::GhosttyTrackedGridRef,
+ callback_state: Box<TerminalCallbackState>,
+ kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
+ kitty_empty_generation: Cell<Option<u64>>,
+ kitty_png_forwarding: bool,
+}
+
+impl Terminal {
+ pub fn new(cols: u16, rows: u16, max_scrollback: usize) -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ // SAFETY: valid out pointer, null allocator means default allocator.
+ unsafe {
+ ffi::ghostty_terminal_new(ptr::null(), &mut raw, cols, rows).into_result()?;
+ }
+
+ let mut terminal = Self {
+ raw,
+ max_scrollback,
+ #[cfg(windows)]
+ tracked_row: ptr::null_mut(),
+ callback_state: Box::new(TerminalCallbackState {
+ size_report: ffi::GhosttySizeReportSize {
+ rows,
+ columns: cols,
+ ..Default::default()
+ },
+ ..Default::default()
+ }),
+ kitty_fingerprints: Mutex::new(HashMap::new()),
+ kitty_empty_generation: Cell::new(None),
+ kitty_png_forwarding: false,
+ };
+ let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast();
+ let glyph_protocol = false;
+ let terminfo_name = ffi::GhosttyString {
+ ptr: TERM.as_ptr().cast(),
+ len: TERM.len(),
+ };
+ let grapheme_default = ffi::GhosttyTerminalModeConfig {
+ mode: MODE_GRAPHEME_CLUSTER,
+ value: true,
+ };
+ let clipboard_callback: ffi::GhosttyTerminalClipboardWriteFn =
+ Some(clipboard_write_trampoline);
+ unsafe {
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_TERMINFO_NAME,
+ (&terminfo_name as *const ffi::GhosttyString).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES,
+ (&max_scrollback as *const usize).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MODE_DEFAULT,
+ (&grapheme_default as *const ffi::GhosttyTerminalModeConfig).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_USERDATA,
+ userdata,
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_SIZE,
+ (size_trampoline as *const ()).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_BELL,
+ (bell_trampoline as *const ()).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD_CHANGED,
+ (pwd_changed_trampoline as *const ()).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
+ clipboard_callback.map_or(ptr::null(), |callback| (callback as *const ()).cast()),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_SCHEME,
+ (color_scheme_trampoline as *const ()).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ terminal.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL,
+ (&glyph_protocol as *const bool).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(terminal)
+ }
+
+ pub fn write(&mut self, bytes: &[u8]) {
+ // SAFETY: self.raw is a live terminal handle for self's lifetime.
+ unsafe {
+ ffi::ghostty_terminal_vt_write(self.raw, bytes.as_ptr(), bytes.len());
+ }
+ }
+
+ pub fn compression_activity(&self) -> Result<u64, Error> {
+ let mut activity = 0;
+ // SAFETY: self.raw is a live terminal handle and activity is a valid out pointer.
+ unsafe {
+ ffi::ghostty_terminal_compression_activity(self.raw, &mut activity).into_result()?;
+ }
+ Ok(activity)
+ }
+
+ pub fn compress_incremental(&mut self) -> Result<TerminalCompressionResult, Error> {
+ let mut result =
+ ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED;
+ // SAFETY: self.raw is a live terminal handle and result is a valid out pointer.
+ unsafe {
+ ffi::ghostty_terminal_compress(
+ self.raw,
+ ffi::GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL,
+ &mut result,
+ )
+ .into_result()?;
+ }
+ match result {
+ ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED => {
+ Ok(TerminalCompressionResult::Unsupported)
+ }
+ ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING => {
+ Ok(TerminalCompressionResult::Pending)
+ }
+ ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE => {
+ Ok(TerminalCompressionResult::Complete)
+ }
+ _ => Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE)),
+ }
+ }
+
+ pub fn set_default_palette(&mut self, palette: &[RgbColor; 256]) -> Result<(), Error> {
+ let palette = palette.map(|color| ffi::GhosttyColorRgb {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ });
+ unsafe {
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_PALETTE,
+ palette.as_ptr().cast(),
+ )
+ .into_result()
+ }
+ }
+
+ pub fn default_palette(&self) -> Result<[RgbColor; 256], Error> {
+ let mut out = [ffi::GhosttyColorRgb::default(); 256];
+ // SAFETY: self.raw is a live terminal handle, and out is exactly the
+ // 256-entry array this data kind writes.
+ unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT,
+ out.as_mut_ptr().cast(),
+ )
+ .into_result()?;
+ }
+ Ok(out.map(Into::into))
+ }
+
+ pub fn resize(
+ &mut self,
+ cols: u16,
+ rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ ) -> Result<(), Error> {
+ let size_report = ffi::GhosttySizeReportSize {
+ rows,
+ columns: cols,
+ cell_width: cell_width_px,
+ cell_height: cell_height_px,
+ };
+ // SAFETY: self.raw is valid and sizes are plain values.
+ unsafe {
+ ffi::ghostty_terminal_resize(
+ self.raw,
+ cols,
+ rows,
+ cell_width_px.max(1),
+ cell_height_px.max(1),
+ )
+ .into_result()?;
+ }
+ self.callback_state.size_report = size_report;
+ Ok(())
+ }
+
+ pub fn enable_kitty_graphics(&mut self) -> Result<(), Error> {
+ install_png_decoder_once();
+ let storage_limit = KITTY_IMAGE_STORAGE_LIMIT_BYTES;
+ let enable_medium = true;
+ let temp_dir = std::env::temp_dir();
+ let temp_dir = temp_dir.to_string_lossy();
+ let temp_dir = ffi::GhosttyString {
+ ptr: temp_dir.as_ptr().cast(),
+ len: temp_dir.len(),
+ };
+ 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,
+ (&enable_medium as *const bool).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE,
+ (&temp_dir as *const ffi::GhosttyString).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM,
+ (&enable_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()?;
+ }
+ self.set_kitty_source_forwarding(true)?;
+ Ok(())
+ }
+
+ pub fn set_kitty_source_forwarding(&mut self, enabled: bool) -> Result<(), Error> {
+ native_source::set_forwarding(self.raw, enabled)
+ }
+
+ #[cfg(test)]
+ fn set_kitty_png_forwarding(&mut self, enabled: bool) -> Result<(), Error> {
+ unsafe {
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG,
+ (&enabled as *const bool).cast(),
+ )
+ .into_result()?;
+ }
+ self.kitty_png_forwarding = enabled;
+ Ok(())
+ }
+
+ pub fn set_write_pty_callback<F>(&mut self, callback: F) -> Result<(), Error>
+ where
+ F: FnMut(&[u8]) + Send + 'static,
+ {
+ unsafe {
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_WRITE_PTY,
+ (write_pty_trampoline as *const ()).cast(),
+ )
+ .into_result()?;
+ }
+ self.callback_state.write_pty = Some(Box::new(callback));
+ Ok(())
+ }
+
+ pub fn set_color_scheme(&mut self, color_scheme: Option<ColorScheme>) -> Option<ColorScheme> {
+ mem::replace(&mut self.callback_state.color_scheme, color_scheme)
+ }
+
+ pub fn take_bell_count(&mut self) -> u16 {
+ mem::take(&mut self.callback_state.bell_count)
+ }
+
+ pub fn take_pwd_changes(&mut self) -> Vec<Vec<u8>> {
+ mem::take(&mut self.callback_state.pwd_changes)
+ }
+
+ pub fn take_clipboard_writes(&mut self) -> Vec<Vec<u8>> {
+ mem::take(&mut self.callback_state.clipboard_writes)
+ }
+
+ pub fn mode_get(&self, mode: u16) -> Result<bool, Error> {
+ let mut config = ffi::GhosttyTerminalModeConfig { mode, value: false };
+ unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MODE,
+ (&mut config as *mut ffi::GhosttyTerminalModeConfig).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(config.value)
+ }
+
+ pub fn mode_set(&mut self, mode: u16, value: bool) -> Result<(), Error> {
+ let config = ffi::GhosttyTerminalModeConfig { mode, value };
+ unsafe {
+ ffi::ghostty_terminal_set(
+ self.raw,
+ ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MODE,
+ (&config as *const ffi::GhosttyTerminalModeConfig).cast(),
+ )
+ .into_result()
+ }
+ }
+
+ pub fn kitty_keyboard_flags(&self) -> Result<u8, Error> {
+ let mut out = 0u8;
+ unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS,
+ (&mut out as *mut u8).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
+ pub fn mouse_tracking_enabled(&self) -> Result<bool, Error> {
+ self.get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING)
+ }
+
+ pub fn modify_other_keys_enabled(&self) -> Result<bool, Error> {
+ self.get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MODIFY_OTHER_KEYS)
+ }
+
+ pub fn active_screen(&self) -> Result<ActiveScreen, Error> {
+ let mut out = ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_PRIMARY;
+ unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN,
+ (&mut out as *mut ffi::GhosttyTerminalScreen).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(match out {
+ ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_PRIMARY => ActiveScreen::Primary,
+ ffi::GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_ALTERNATE => ActiveScreen::Alternate,
+ _ => ActiveScreen::Primary,
+ })
+ }
+
+ pub fn total_rows(&self) -> Result<usize, Error> {
+ self.get_usize(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_TOTAL_ROWS)
+ }
+
+ pub fn scrollback_rows(&self) -> Result<usize, Error> {
+ self.get_usize(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS)
+ }
+
+ pub fn max_scrollback(&self) -> usize {
+ self.max_scrollback
+ }
+
+ pub fn scrollbar(&self) -> Result<TerminalScrollbar, Error> {
+ let mut out = ffi::GhosttyTerminalScrollbar::default();
+ unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SCROLLBAR,
+ (&mut out as *mut ffi::GhosttyTerminalScrollbar).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(TerminalScrollbar {
+ total: out.total as usize,
+ offset: out.offset as usize,
+ len: out.len as usize,
+ })
+ }
+
+ #[cfg(windows)]
+ pub fn track_row(&mut self, y: u32) -> Option<usize> {
+ let mut point = ffi::GhosttyPointCoordinate::default();
+ let tag = ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN;
+ let result =
+ unsafe { ffi::ghostty_tracked_grid_ref_point(self.tracked_row, tag, &mut point) };
+ let terminal = self.raw;
+ let target = ghostty_viewport_point(0, y);
+ unsafe {
+ ffi::ghostty_tracked_grid_ref_free(self.tracked_row);
+ self.tracked_row = ptr::null_mut();
+ let _ = ffi::ghostty_terminal_grid_ref_track(terminal, target, &mut self.tracked_row);
+ }
+ (result == ffi::GhosttyResult_GHOSTTY_SUCCESS).then_some(point.y as usize)
+ }
+
+ pub fn screen_cell(&self, x: u16, y: u32) -> Result<(CellWide, Vec<u32>), Error> {
+ let grid_ref = self.grid_ref(ghostty_screen_point(x, y))?;
+ let wide = grid_ref_wide(&grid_ref)?;
+ let graphemes = grid_ref_graphemes(&grid_ref)?;
+ Ok((wide, graphemes))
+ }
+
+ pub fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> {
+ self.screen_text_rows_range(0, usize::MAX)
+ }
+
+ pub fn screen_text_rows_range(
+ &self,
+ start_row: usize,
+ end_row_exclusive: usize,
+ ) -> Result<Vec<ScreenTextRow>, Error> {
+ let total_rows = self.total_rows()?;
+ let start_row = start_row.min(total_rows);
+ let end_row_exclusive = end_row_exclusive.min(total_rows).max(start_row);
+ let cols = self.cols()?;
+ let mut rows = Vec::with_capacity(end_row_exclusive.saturating_sub(start_row));
+ for y in start_row..end_row_exclusive {
+ let Some(y) = u32::try_from(y).ok() else {
+ break;
+ };
+ let mut grid_ref = self.grid_ref(ghostty_screen_point(0, y))?;
+ let (soft_wrapped, wrap_continuation) = grid_ref_wrap_state(&grid_ref)?;
+ let mut cells = Vec::with_capacity(usize::from(cols));
+ for x in 0..cols {
+ grid_ref.x = x;
+ cells.push(ScreenTextCell {
+ wide: grid_ref_wide(&grid_ref)?,
+ graphemes: grid_ref_graphemes(&grid_ref)?,
+ });
+ }
+ rows.push(ScreenTextRow {
+ cells,
+ soft_wrapped,
+ wrap_continuation,
+ });
+ }
+ Ok(rows)
+ }
+
+ fn viewport_graphemes_and_style(&self, x: u16, y: u32) -> Result<(Vec<u32>, CellStyle), Error> {
+ let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?;
+ let graphemes = grid_ref_graphemes(&grid_ref)?;
+ let mut style = ffi::GhosttyStyle {
+ size: mem::size_of::<ffi::GhosttyStyle>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_grid_ref_style(&grid_ref, &mut style).into_result()?;
+ }
+ Ok((graphemes, style.into()))
+ }
+
+ pub fn viewport_hyperlink_uri(&self, x: u16, y: u32) -> Result<Option<String>, Error> {
+ let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?;
+ grid_ref_hyperlink_uri(&grid_ref)
+ }
+
+ pub fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> {
+ Ok(self
+ .viewport_link_selection(x, y)?
+ .map(|(target, _)| target))
+ }
+
+ fn viewport_link_selection(
+ &self,
+ x: u16,
+ y: u32,
+ ) -> Result<Option<(LinkTarget, Option<ffi::GhosttySelection>)>, Error> {
+ let mut clicked = self.grid_ref(ghostty_viewport_point(x, y))?;
+ if grid_ref_wide(&clicked)? == CellWide::SpacerTail {
+ clicked.x = clicked.x.saturating_sub(1);
+ }
+ if let Some(uri) = grid_ref_hyperlink_uri(&clicked)? {
+ return Ok(Some((LinkTarget::Uri(uri), None)));
+ }
+ let graphemes = grid_ref_graphemes(&clicked)?;
+ if graphemes
+ .first()
+ .copied()
+ .and_then(char::from_u32)
+ .is_none_or(char::is_whitespace)
+ {
+ return Ok(None);
+ }
+ // Unicode White_Space, matching Rust's char::is_whitespace URL boundaries.
+ const BOUNDARIES: &[u32] = &[
+ 9, 10, 11, 12, 13, 32, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004,
+ 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000,
+ ];
+ let options = ffi::GhosttyTerminalSelectWordOptions {
+ size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(),
+ ref_: clicked,
+ boundary_codepoints: BOUNDARIES.as_ptr(),
+ boundary_codepoints_len: BOUNDARIES.len(),
+ };
+ let mut selection = ffi::GhosttySelection::default();
+ // Bound work before formatting, even for an unbroken scrollback-sized token.
+ let result = unsafe {
+ ffi::ghostty_terminal_select_word_bounded(self.raw, &options, 8192, &mut selection)
+ };
+ if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
+ return Ok(None);
+ }
+ result.into_result()?;
+ let text = self.format_selection(&selection, FormatterFormat::Plain, true, false)?;
+ let prefix = self.format_selection(
+ &ffi::GhosttySelection {
+ end: clicked,
+ ..selection
+ },
+ FormatterFormat::Plain,
+ true,
+ false,
+ )?;
+ let clicked_len: usize = graphemes
+ .into_iter()
+ .filter_map(char::from_u32)
+ .map(char::len_utf8)
+ .sum();
+ Ok(Some((
+ LinkTarget::Text {
+ text,
+ clicked_byte: prefix.len().saturating_sub(clicked_len),
+ },
+ Some(selection),
+ )))
+ }
+
+ /// Resolve only the bounded plain-text token. OSC 8 regions are resolved by
+ /// clients from frame hyperlink IDs; their full URI activation path is unchanged.
+ pub fn viewport_link_regions(
+ &self,
+ x: u16,
+ y: u32,
+ resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>,
+ ) -> Result<Vec<LinkRegion>, Error> {
+ let cols = self.cols()?;
+ let rows = self.rows()?;
+ if x >= cols || y >= u32::from(rows) {
+ return Ok(Vec::new());
+ }
+ let Some((LinkTarget::Text { text, clicked_byte }, Some(selection))) =
+ self.viewport_link_selection(x, y)?
+ else {
+ return Ok(Vec::new());
+ };
+ let Some(range) = resolve(&text, clicked_byte) else {
+ return Ok(Vec::new());
+ };
+ let point =
+ |grid_ref: &ffi::GhosttyGridRef, clipped: (u16, u32)| -> Result<(u16, u32), Error> {
+ let mut out = ffi::GhosttyPointCoordinate::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_point_from_grid_ref(
+ self.raw,
+ grid_ref,
+ ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT,
+ &mut out,
+ )
+ };
+ if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
+ return Ok(clipped);
+ }
+ result.into_result()?;
+ // C viewport coordinates can extend below the visible viewport
+ // when scrolled up. Clip those as well as NO_VALUE endpoints.
+ if out.y >= u32::from(rows) {
+ return Ok(clipped);
+ }
+ Ok((out.x, out.y))
+ };
+ // The selected token contains the visible hovered cell, so an invisible
+ // start/end necessarily lies above/below the viewport respectively.
+ let (start_col, start_row) = point(&selection.start, (0, 0))?;
+ let (end_col, end_row) = point(&selection.end, (cols - 1, u32::from(rows - 1)))?;
+ let first = self.grid_ref(ghostty_viewport_point(start_col, start_row))?;
+ let cell_len = |cell: &ffi::GhosttyGridRef| -> Result<usize, Error> {
+ if matches!(
+ grid_ref_wide(cell)?,
+ CellWide::SpacerHead | CellWide::SpacerTail
+ ) {
+ return Ok(0);
+ }
+ Ok(grid_ref_graphemes(cell)?
+ .into_iter()
+ .filter_map(char::from_u32)
+ .map(char::len_utf8)
+ .sum())
+ };
+ // Format a prefix once, not once per cell. Then walk only the selected
+ // visible cells, retaining one page/grid lookup per row.
+ let prefix = self.format_selection(
+ &ffi::GhosttySelection {
+ end: first,
+ ..selection
+ },
+ FormatterFormat::Plain,
+ true,
+ false,
+ )?;
+ let mut byte = prefix.len().saturating_sub(cell_len(&first)?);
+ let mut regions: Vec<LinkRegion> = Vec::new();
+ for row in start_row..=end_row {
+ let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?;
+ let left = if row == start_row { start_col } else { 0 };
+ let right = if row == end_row { end_col } else { cols - 1 };
+ for col in left..=right {
+ cell.x = col;
+ let wide = grid_ref_wide(&cell)?;
+ if matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) {
+ continue;
+ }
+ let len = cell_len(&cell)?;
+ if byte < range.end && byte + len > range.start {
+ let end = if wide == CellWide::Wide {
+ (col + 1).min(cols - 1)
+ } else {
+ col
+ };
+ if let Some(last) = regions
+ .last_mut()
+ .filter(|last| u32::from(last.row) == row && last.end_col + 1 == col)
+ {
+ last.end_col = end;
+ } else {
+ regions.push(LinkRegion {
+ row: row as u16,
+ start_col: col,
+ end_col: end,
+ });
+ }
+ }
+ byte += len;
+ }
+ }
+ Ok(regions)
+ }
+
+ fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result<ffi::GhosttyGridRef, Error> {
+ let mut grid_ref = ffi::GhosttyGridRef {
+ size: mem::size_of::<ffi::GhosttyGridRef>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_terminal_grid_ref(self.raw, point, &mut grid_ref).into_result()?;
+ }
+ Ok(grid_ref)
+ }
+
+ pub fn read_text_viewport(
+ &self,
+ start: (u16, u32),
+ end: (u16, u32),
+ rectangle: bool,
+ ) -> Result<String, Error> {
+ self.read_formatted_selection(
+ ghostty_viewport_point(start.0, start.1),
+ ghostty_viewport_point(end.0, end.1),
+ rectangle,
+ FormatterFormat::Plain,
+ true,
+ true,
+ )
+ }
+
+ pub fn read_ansi_viewport(
+ &self,
+ start: (u16, u32),
+ end: (u16, u32),
+ rectangle: bool,
+ ) -> Result<String, Error> {
+ self.read_formatted_selection(
+ ghostty_viewport_point(start.0, start.1),
+ ghostty_viewport_point(end.0, end.1),
+ rectangle,
+ FormatterFormat::Vt,
+ false,
+ true,
+ )
+ }
+
+ pub fn read_text_screen(
+ &self,
+ start: (u16, u32),
+ end: (u16, u32),
+ rectangle: bool,
+ ) -> Result<String, Error> {
+ self.read_formatted_selection(
+ ghostty_screen_point(start.0, start.1),
+ ghostty_screen_point(end.0, end.1),
+ rectangle,
+ FormatterFormat::Plain,
+ true,
+ true,
+ )
+ }
+
+ pub fn read_ansi_screen(
+ &self,
+ start: (u16, u32),
+ end: (u16, u32),
+ rectangle: bool,
+ unwrap: bool,
+ ) -> Result<String, Error> {
+ self.read_formatted_selection(
+ ghostty_screen_point(start.0, start.1),
+ ghostty_screen_point(end.0, end.1),
+ rectangle,
+ FormatterFormat::Vt,
+ unwrap,
+ true,
+ )
+ }
+
+ fn read_formatted_selection(
+ &self,
+ start: ffi::GhosttyPoint,
+ end: ffi::GhosttyPoint,
+ rectangle: bool,
+ format: FormatterFormat,
+ unwrap: bool,
+ trim: bool,
+ ) -> Result<String, Error> {
+ let mut start_ref = ffi::GhosttyGridRef {
+ size: mem::size_of::<ffi::GhosttyGridRef>(),
+ ..Default::default()
+ };
+ let mut end_ref = ffi::GhosttyGridRef {
+ size: mem::size_of::<ffi::GhosttyGridRef>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_terminal_grid_ref(self.raw, start, &mut start_ref).into_result()?;
+ ffi::ghostty_terminal_grid_ref(self.raw, end, &mut end_ref).into_result()?;
+ }
+
+ let selection = ffi::GhosttySelection {
+ size: mem::size_of::<ffi::GhosttySelection>(),
+ start: start_ref,
+ end: end_ref,
+ rectangle,
+ };
+ self.format_selection(&selection, format, unwrap, trim)
+ }
+
+ fn format_selection(
+ &self,
+ selection: &ffi::GhosttySelection,
+ format: FormatterFormat,
+ unwrap: bool,
+ trim: bool,
+ ) -> Result<String, Error> {
+ let mut formatter: ffi::GhosttyFormatter = ptr::null_mut();
+ let options = ffi::GhosttyFormatterTerminalOptions {
+ size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(),
+ emit: format.as_raw(),
+ unwrap,
+ trim,
+ extra: ffi::GhosttyFormatterTerminalExtra {
+ size: mem::size_of::<ffi::GhosttyFormatterTerminalExtra>(),
+ screen: ffi::GhosttyFormatterScreenExtra {
+ size: mem::size_of::<ffi::GhosttyFormatterScreenExtra>(),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ selection,
+ };
+ unsafe {
+ ffi::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, options)
+ .into_result()?;
+ }
+
+ let mut out_ptr = ptr::null_mut();
+ let mut out_len = 0usize;
+ let result = unsafe {
+ ffi::ghostty_formatter_format_alloc(formatter, ptr::null(), &mut out_ptr, &mut out_len)
+ };
+ unsafe {
+ ffi::ghostty_formatter_free(formatter);
+ }
+ result.into_result()?;
+
+ let text = if out_len == 0 {
+ String::new()
+ } else {
+ let bytes = unsafe { slice::from_raw_parts(out_ptr.cast_const(), out_len) };
+ String::from_utf8_lossy(bytes).into_owned()
+ };
+
+ if !out_ptr.is_null() {
+ unsafe {
+ ffi::ghostty_free(ptr::null(), out_ptr, out_len);
+ }
+ }
+
+ Ok(text)
+ }
+
+ pub fn clear_screen(&mut self) -> bool {
+ unsafe { ffi::ghostty_terminal_clear_screen(self.raw) }
+ }
+
+ pub fn scroll_viewport_bottom(&mut self) {
+ let viewport = ffi::GhosttyTerminalScrollViewport {
+ tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM,
+ value: ffi::GhosttyTerminalScrollViewportValue::default(),
+ };
+ // SAFETY: self.raw is valid and viewport value matches the tag.
+ unsafe {
+ ffi::ghostty_terminal_scroll_viewport(self.raw, viewport);
+ }
+ }
+
+ pub fn scroll_viewport_delta(&mut self, delta: isize) {
+ let viewport = ffi::GhosttyTerminalScrollViewport {
+ tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_DELTA,
+ value: ffi::GhosttyTerminalScrollViewportValue { delta },
+ };
+ // SAFETY: self.raw is valid and viewport value matches the tag.
+ unsafe {
+ ffi::ghostty_terminal_scroll_viewport(self.raw, viewport);
+ }
+ }
+
+ pub fn scroll_viewport_row(&mut self, row: usize) {
+ let viewport = ffi::GhosttyTerminalScrollViewport {
+ tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_ROW,
+ value: ffi::GhosttyTerminalScrollViewportValue { row },
+ };
+ // SAFETY: self.raw is valid and viewport value matches the tag.
+ unsafe {
+ ffi::ghostty_terminal_scroll_viewport(self.raw, viewport);
+ }
+ }
+
+ pub fn cols(&self) -> Result<u16, Error> {
+ self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLS)
+ }
+
+ pub fn rows(&self) -> Result<u16, Error> {
+ self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_ROWS)
+ }
+
+ pub fn cursor_y(&self) -> Result<u16, Error> {
+ self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_CURSOR_Y)
+ }
+
+ pub fn effective_foreground_color(&self) -> Result<Option<RgbColor>, Error> {
+ self.get_optional_rgb_color(TERMINAL_DATA_COLOR_FOREGROUND)
+ }
+
+ pub fn effective_cursor_color(&self) -> Result<Option<RgbColor>, Error> {
+ self.get_optional_rgb_color(TERMINAL_DATA_COLOR_CURSOR)
+ }
+
+ pub fn width_px(&self) -> Result<u32, Error> {
+ self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX)
+ }
+
+ pub fn height_px(&self) -> Result<u32, Error> {
+ self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX)
+ }
+
+ fn get_u16(&self, data: ffi::GhosttyTerminalData) -> Result<u16, Error> {
+ let mut out = 0u16;
+ // SAFETY: out points to a u16 matching the requested terminal data type.
+ unsafe {
+ ffi::ghostty_terminal_get(self.raw, data, (&mut out as *mut u16).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
+ fn get_u32(&self, data: ffi::GhosttyTerminalData) -> Result<u32, Error> {
+ let mut out = 0u32;
+ // SAFETY: out points to a u32 matching the requested terminal data type.
+ unsafe {
+ ffi::ghostty_terminal_get(self.raw, data, (&mut out as *mut u32).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
+ fn get_usize(&self, data: ffi::GhosttyTerminalData) -> Result<usize, Error> {
+ let mut out = 0usize;
+ unsafe {
+ ffi::ghostty_terminal_get(self.raw, data, (&mut out as *mut usize).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
+ fn get_bool(&self, data: ffi::GhosttyTerminalData) -> Result<bool, Error> {
+ let mut out = false;
+ unsafe {
+ ffi::ghostty_terminal_get(self.raw, data, (&mut out as *mut bool).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
+ fn get_optional_rgb_color(
+ &self,
+ data: ffi::GhosttyTerminalData,
+ ) -> Result<Option<RgbColor>, Error> {
+ let mut out = ffi::GhosttyColorRgb::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_get(
+ self.raw,
+ data,
+ (&mut out as *mut ffi::GhosttyColorRgb).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => Ok(Some(out.into())),
+ ffi::GhosttyResult_GHOSTTY_NO_VALUE => Ok(None),
+ other => Err(Error(other)),
+ }
+ }
+
+ fn kitty_graphics(&self) -> Result<ffi::GhosttyKittyGraphics, 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()?;
+ }
+ Ok(graphics)
+ }
+
+ pub fn kitty_graphics_generation(&self) -> Result<u64, Error> {
+ let graphics = self.kitty_graphics()?;
+ if graphics.is_null() {
+ return Ok(0);
+ }
+ kitty_graphics_u64(
+ graphics,
+ ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION,
+ )
+ }
+
+ pub fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> {
+ let generation = self.kitty_graphics_generation()?;
+ Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation))
+ }
+
+ pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> {
+ self.kitty_image_placements_with_data_filter(|_| true)
+ }
+
+ pub fn kitty_image_placements_with_data_filter<F>(
+ &self,
+ mut needs_data: F,
+ ) -> Result<Vec<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
+ let graphics = self.kitty_graphics()?;
+ if graphics.is_null() {
+ return Ok(Vec::new());
+ }
+ let generation = kitty_graphics_u64(
+ graphics,
+ ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION,
+ )?;
+ if generation == 0 || self.kitty_empty_generation.get() == Some(generation) {
+ 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();
+ let mut storage_has_placements = false;
+ while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } {
+ storage_has_placements = true;
+ if let Some(placement) =
+ self.kitty_image_placement(graphics, iterator, &mut needs_data)?
+ {
+ placements.push(placement);
+ }
+ }
+ if !storage_has_placements {
+ self.kitty_empty_generation.set(Some(generation));
+ self.prune_kitty_fingerprints(&[]);
+ return Ok(Vec::new());
+ }
+
+ placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?);
+ placements.sort_by_key(|placement| placement.z);
+ self.prune_kitty_fingerprints(&placements);
+ Ok(placements)
+ }
+
+ /// Fingerprint for `image`, cached per image id and recomputed only when
+ /// the image's generation changes.
+ fn kitty_image_fingerprint_cached(
+ &self,
+ image: ffi::GhosttyKittyGraphicsImage,
+ image_id: u32,
+ data: (*const u8, usize),
+ image_width: u32,
+ image_height: u32,
+ format: KittyImageFormat,
+ ) -> u64 {
+ let (data_ptr, data_len) = data;
+ let Ok(generation) = kitty_image_u64(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION,
+ ) else {
+ return kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
+ };
+
+ if let Ok(cache) = self.kitty_fingerprints.lock() {
+ if let Some(entry) = cache.get(&image_id) {
+ if entry.generation == generation {
+ return entry.fingerprint;
+ }
+ }
+ }
+
+ let fingerprint =
+ kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
+ if let Ok(mut cache) = self.kitty_fingerprints.lock() {
+ cache.insert(
+ image_id,
+ KittyImageFingerprintEntry {
+ generation,
+ fingerprint,
+ },
+ );
+ }
+ fingerprint
+ }
+
+ fn prune_kitty_fingerprints(&self, placements: &[KittyImagePlacement]) {
+ if let Ok(mut cache) = self.kitty_fingerprints.lock() {
+ if cache.is_empty() {
+ return;
+ }
+ let live: HashSet<u32> = placements
+ .iter()
+ .map(|placement| placement.image_id)
+ .collect();
+ cache.retain(|image_id, _| live.contains(image_id));
+ }
+ }
+
+ // Retained files are authoritative: never ask the native lazy decoder for
+ // pixels just to describe a placement or calculate its cache identity.
+ fn kitty_placement_payload<F>(
+ &self,
+ image: ffi::GhosttyKittyGraphicsImage,
+ image_id: u32,
+ placement_id: u32,
+ image_width: u32,
+ image_height: u32,
+ decoded_format: KittyImageFormat,
+ needs_data: &mut F,
+ ) -> Result<KittyPlacementPayload, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
+ let source_file = native_source::image_source(image)?;
+ let (format, data_ptr, data_len, data_fingerprint) = if let Some(source) = &source_file {
+ (
+ KittyImageFormat::Rgba,
+ ptr::null(),
+ source.len(),
+ source.fingerprint(),
+ )
+ } else {
+ let (format, data_ptr, data_len) =
+ kitty_image_transmission_payload(image, decoded_format, self.kitty_png_forwarding)?;
+ let fingerprint = self.kitty_image_fingerprint_cached(
+ image,
+ image_id,
+ (data_ptr, data_len),
+ image_width,
+ image_height,
+ format,
+ );
+ (format, data_ptr, data_len, fingerprint)
+ };
+ let descriptor = KittyImageDescriptor {
+ image_id,
+ placement_id,
+ image_width,
+ image_height,
+ format,
+ data_len,
+ data_fingerprint,
+ source_file: source_file.is_some(),
+ };
+ let data = if needs_data(descriptor) {
+ match &source_file {
+ Some(source) => source
+ .copy_rgba()
+ .map_err(|_| Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE))?,
+ None => kitty_image_data_from_ptr(data_ptr, data_len),
+ }
+ } else {
+ Vec::new()
+ };
+ Ok((descriptor, data, source_file))
+ }
+
+ fn kitty_image_placement<F>(
+ &self,
+ graphics: ffi::GhosttyKittyGraphics,
+ iterator: ffi::GhosttyKittyGraphicsPlacementIterator,
+ needs_data: &mut F,
+ ) -> Result<Option<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
+ let image_id = kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID,
+ )?;
+ if kitty_placement_bool(iterator, KITTY_PLACEMENT_DATA_IS_VIRTUAL)? {
+ return Ok(None);
+ }
+ 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 placement_id = kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
+ )?;
+ let (descriptor, data, source_file) = self.kitty_placement_payload(
+ image,
+ image_id,
+ placement_id,
+ image_width,
+ image_height,
+ format,
+ needs_data,
+ )?;
+ let KittyImageDescriptor {
+ format,
+ data_len,
+ data_fingerprint,
+ ..
+ } = descriptor;
+ 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_len,
+ data_fingerprint,
+ data,
+ source_file,
+ 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 kitty_virtual_image_placements<F>(
+ &self,
+ graphics: ffi::GhosttyKittyGraphics,
+ needs_data: &mut F,
+ ) -> Result<Vec<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
+ let specs = kitty_virtual_placement_specs(graphics)?;
+ if specs.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let viewport_cols = self.cols()?.max(1);
+ let viewport_rows = self.rows()?.max(1);
+ let cell_width = (self.width_px()? / u32::from(viewport_cols)).max(1);
+ let cell_height = (self.height_px()? / u32::from(viewport_rows)).max(1);
+ let mut runs = Vec::new();
+ for y in 0..viewport_rows {
+ let mut current: Option<KittyVirtualRun> = None;
+ for x in 0..viewport_cols {
+ let (graphemes, style) = self.viewport_graphemes_and_style(x, u32::from(y))?;
+ let cell = kitty_virtual_cell(x, y, &graphemes, style);
+ match cell {
+ Some(cell) => {
+ if let Some(run) = current.as_mut() {
+ if run.append(cell) {
+ continue;
+ }
+ runs.push(*run);
+ }
+ current = Some(KittyVirtualRun::from_cell(cell));
+ }
+ None => {
+ if let Some(run) = current.take() {
+ runs.push(run);
+ }
+ }
+ }
+ }
+ if let Some(run) = current {
+ runs.push(run);
+ }
+ }
+
+ let mut placements = Vec::new();
+ for run in runs {
+ let image_id = run.image_id();
+ let Some(spec) = find_virtual_placement_spec(&specs, image_id, run.placement_id())
+ else {
+ continue;
+ };
+ let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) };
+ if image.is_null() {
+ continue;
+ }
+ 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
+ {
+ continue;
+ }
+ let Some(geometry) = kitty_virtual_placement_geometry(
+ run,
+ *spec,
+ image_width,
+ image_height,
+ cell_width,
+ cell_height,
+ ) else {
+ continue;
+ };
+ let placement_id = run.synthetic_placement_id();
+ let (descriptor, data, source_file) = self.kitty_placement_payload(
+ image,
+ image_id,
+ placement_id,
+ image_width,
+ image_height,
+ format,
+ needs_data,
+ )?;
+ let KittyImageDescriptor {
+ format,
+ data_len,
+ data_fingerprint,
+ ..
+ } = descriptor;
+ placements.push(KittyImagePlacement {
+ image_id,
+ placement_id,
+ z: spec.z,
+ x_offset: geometry.x_offset,
+ y_offset: geometry.y_offset,
+ image_width,
+ image_height,
+ format,
+ data_len,
+ data_fingerprint,
+ data,
+ source_file,
+ render: geometry.render,
+ });
+ }
+
+ Ok(placements)
+ }
+
+ fn raw(&self) -> ffi::GhosttyTerminal {
+ 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 {}
+
+impl Drop for Terminal {
+ fn drop(&mut self) {
+ // SAFETY: freeing a null or live handle is allowed by the C API.
+ unsafe {
+ #[cfg(windows)]
+ ffi::ghostty_tracked_grid_ref_free(self.tracked_row);
+ ffi::ghostty_terminal_free(self.raw);
+ }
+ }
+}
+
+fn ghostty_viewport_point(x: u16, y: u32) -> ffi::GhosttyPoint {
+ ffi::GhosttyPoint {
+ tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT,
+ value: ffi::GhosttyPointValue {
+ coordinate: ffi::GhosttyPointCoordinate { x, y },
+ },
+ }
+}
+
+fn ghostty_screen_point(x: u16, y: u32) -> ffi::GhosttyPoint {
+ ffi::GhosttyPoint {
+ tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN,
+ value: ffi::GhosttyPointValue {
+ coordinate: ffi::GhosttyPointCoordinate { x, y },
+ },
+ }
+}
+
+fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result<Vec<u32>, Error> {
+ let mut required = 0usize;
+ let result =
+ unsafe { ffi::ghostty_grid_ref_graphemes(grid_ref, ptr::null_mut(), 0, &mut required) };
+ if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
+ result.into_result()?;
+ }
+ let mut buffer = vec![0u32; required];
+ if required == 0 {
+ return Ok(buffer);
+ }
+ unsafe {
+ ffi::ghostty_grid_ref_graphemes(grid_ref, buffer.as_mut_ptr(), buffer.len(), &mut required)
+ .into_result()?;
+ }
+ buffer.truncate(required);
+ Ok(buffer)
+}
+
+fn grid_ref_wide(grid_ref: &ffi::GhosttyGridRef) -> Result<CellWide, Error> {
+ let mut raw = ffi::GhosttyCell::default();
+ unsafe {
+ ffi::ghostty_grid_ref_cell(grid_ref, &mut raw).into_result()?;
+ }
+
+ let mut wide = ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_WIDE,
+ (&mut wide as *mut ffi::GhosttyCellWide).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(CellWide::from_raw(wide))
+}
+
+fn grid_ref_wrap_state(grid_ref: &ffi::GhosttyGridRef) -> Result<(bool, bool), Error> {
+ let mut row = 0;
+ unsafe {
+ ffi::ghostty_grid_ref_row(grid_ref, &mut row).into_result()?;
+ }
+ let mut soft_wrapped = false;
+ let mut wrap_continuation = false;
+ unsafe {
+ ffi::ghostty_row_get(
+ row,
+ ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP,
+ (&mut soft_wrapped as *mut bool).cast(),
+ )
+ .into_result()?;
+ ffi::ghostty_row_get(
+ row,
+ ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP_CONTINUATION,
+ (&mut wrap_continuation as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ Ok((soft_wrapped, wrap_continuation))
+}
+
+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_placement_bool(
+ iterator: ffi::GhosttyKittyGraphicsPlacementIterator,
+ data: ffi::GhosttyKittyGraphicsPlacementData,
+) -> Result<bool, Error> {
+ let mut out = false;
+ unsafe {
+ ffi::ghostty_kitty_graphics_placement_get(iterator, data, (&mut out as *mut bool).cast())
+ .into_result()?;
+ }
+ Ok(out)
+}
+
+fn kitty_virtual_placement_specs(
+ graphics: ffi::GhosttyKittyGraphics,
+) -> Result<Vec<KittyVirtualPlacementSpec>, Error> {
+ 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 specs = Vec::new();
+ while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } {
+ if !kitty_placement_bool(iterator, KITTY_PLACEMENT_DATA_IS_VIRTUAL)? {
+ continue;
+ }
+ specs.push(KittyVirtualPlacementSpec {
+ image_id: kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID,
+ )?,
+ placement_id: kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
+ )?,
+ columns: kitty_placement_u32(iterator, KITTY_PLACEMENT_DATA_COLUMNS)?,
+ rows: kitty_placement_u32(iterator, KITTY_PLACEMENT_DATA_ROWS)?,
+ z: kitty_placement_i32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z,
+ )?,
+ });
+ }
+ Ok(specs)
+}
+
+fn find_virtual_placement_spec(
+ specs: &[KittyVirtualPlacementSpec],
+ image_id: u32,
+ placement_id: u32,
+) -> Option<&KittyVirtualPlacementSpec> {
+ if placement_id > 0 {
+ specs
+ .iter()
+ .find(|spec| spec.image_id == image_id && spec.placement_id == placement_id)
+ } else {
+ specs.iter().find(|spec| spec.image_id == image_id)
+ }
+}
+
+fn kitty_virtual_cell(
+ x: u16,
+ y: u16,
+ graphemes: &[u32],
+ style: CellStyle,
+) -> Option<KittyVirtualCell> {
+ if graphemes.first().copied() != Some(KITTY_UNICODE_PLACEHOLDER) {
+ return None;
+ }
+ let image_id_low = style
+ .fg_color
+ .map(kitty_placeholder_color_to_id)
+ .unwrap_or(0);
+ let placement_id = style
+ .underline_color
+ .map(kitty_placeholder_color_to_id)
+ .filter(|id| *id != 0);
+ let row = graphemes
+ .get(1)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint));
+ let col = graphemes
+ .get(2)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint));
+ let image_id_high = graphemes
+ .get(3)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint))
+ .filter(|high| *high <= u32::from(u8::MAX));
+
+ Some(KittyVirtualCell {
+ x,
+ y,
+ image_id_low,
+ image_id_high,
+ placement_id,
+ row,
+ col,
+ })
+}
+
+fn kitty_placeholder_color_to_id(color: CellColor) -> u32 {
+ match color {
+ CellColor::Palette(value) => value.into(),
+ CellColor::Rgb(color) => {
+ (u32::from(color.r) << 16) | (u32::from(color.g) << 8) | u32::from(color.b)
+ }
+ }
+}
+
+fn kitty_placeholder_diacritic_index(codepoint: u32) -> Option<u32> {
+ let map = KITTY_PLACEHOLDER_DIACRITICS.get_or_init(|| {
+ // Reuse Ghostty's vendored table so Herdr decodes the same placeholder
+ // row/column diacritics that libghostty accepts.
+ let source =
+ include_str!("../../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig");
+ let mut map = HashMap::new();
+ let mut in_table = false;
+ for line in source.lines() {
+ let line = line.trim();
+ if line.starts_with("const diacritics:") {
+ in_table = true;
+ continue;
+ }
+ if !in_table {
+ continue;
+ }
+ if line == "};" {
+ break;
+ }
+ let Some(hex) = line
+ .strip_prefix("0x")
+ .and_then(|value| value.strip_suffix(','))
+ else {
+ continue;
+ };
+ if let Ok(value) = u32::from_str_radix(hex, 16) {
+ map.insert(value, map.len() as u32);
+ }
+ }
+ map
+ });
+ map.get(&codepoint).copied()
+}
+
+fn kitty_virtual_placement_geometry(
+ run: KittyVirtualRun,
+ spec: KittyVirtualPlacementSpec,
+ image_width: u32,
+ image_height: u32,
+ cell_width: u32,
+ cell_height: u32,
+) -> Option<KittyVirtualPlacementGeometry> {
+ let grid_cols = if spec.columns == 0 {
+ image_width.saturating_add(cell_width - 1) / cell_width
+ } else {
+ spec.columns
+ }
+ .max(1);
+ let grid_rows = if spec.rows == 0 {
+ image_height.saturating_add(cell_height - 1) / cell_height
+ } else {
+ spec.rows
+ }
+ .max(1);
+
+ if run.col >= grid_cols || run.row >= grid_rows {
+ return None;
+ }
+ let visible_cols = run.width.min(grid_cols.saturating_sub(run.col)).max(1);
+ let visible_rows = 1;
+ let source_x = scale_u32(run.col, image_width, grid_cols);
+ let source_y = scale_u32(run.row, image_height, grid_rows);
+ let source_width = scale_u32(visible_cols, image_width, grid_cols)
+ .max(1)
+ .min(image_width.saturating_sub(source_x));
+ let source_height = scale_u32(visible_rows, image_height, grid_rows)
+ .max(1)
+ .min(image_height.saturating_sub(source_y));
+ if source_width == 0 || source_height == 0 {
+ return None;
+ }
+
+ Some(KittyVirtualPlacementGeometry {
+ x_offset: 0,
+ y_offset: 0,
+ render: KittyPlacementRenderInfo {
+ pixel_width: visible_cols.saturating_mul(cell_width).max(1),
+ pixel_height: visible_rows.saturating_mul(cell_height).max(1),
+ grid_cols: visible_cols,
+ grid_rows: visible_rows,
+ viewport_col: i32::from(run.x),
+ viewport_row: i32::from(run.y),
+ source_x,
+ source_y,
+ source_width,
+ source_height,
+ },
+ })
+}
+
+fn scale_u32(value: u32, source: u32, dest: u32) -> u32 {
+ ((u64::from(value)).saturating_mul(u64::from(source)) / u64::from(dest.max(1)))
+ .min(u64::from(u32::MAX)) as u32
+}
+
+impl KittyVirtualRun {
+ fn from_cell(cell: KittyVirtualCell) -> Self {
+ Self {
+ x: cell.x,
+ y: cell.y,
+ image_id_low: cell.image_id_low,
+ image_id_high: cell.image_id_high,
+ placement_id: cell.placement_id,
+ row: cell.row.unwrap_or(0),
+ col: cell.col.unwrap_or(0),
+ width: 1,
+ }
+ }
+
+ fn append(&mut self, cell: KittyVirtualCell) -> bool {
+ if self.image_id_low != cell.image_id_low
+ || self.placement_id != cell.placement_id
+ || cell.row.is_some_and(|row| row != self.row)
+ || cell.col.is_some_and(|col| col != self.col + self.width)
+ || cell
+ .image_id_high
+ .is_some_and(|high| Some(high) != self.image_id_high)
+ {
+ return false;
+ }
+ self.width += 1;
+ true
+ }
+
+ fn image_id(self) -> u32 {
+ self.image_id_low | (self.image_id_high.unwrap_or(0) << 24)
+ }
+
+ fn placement_id(self) -> u32 {
+ self.placement_id.unwrap_or(0)
+ }
+
+ fn synthetic_placement_id(self) -> u32 {
+ let mut hasher = DefaultHasher::new();
+ self.image_id().hash(&mut hasher);
+ self.placement_id().hash(&mut hasher);
+ self.row.hash(&mut hasher);
+ self.col.hash(&mut hasher);
+ self.width.hash(&mut hasher);
+ self.x.hash(&mut hasher);
+ self.y.hash(&mut hasher);
+ 1 + ((hasher.finish() as u32) % 900_000)
+ }
+}
+
+fn kitty_graphics_u64(
+ graphics: ffi::GhosttyKittyGraphics,
+ data: ffi::GhosttyKittyGraphicsData,
+) -> Result<u64, Error> {
+ let mut out = 0u64;
+ unsafe {
+ ffi::ghostty_kitty_graphics_get(graphics, data, (&mut out as *mut u64).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_u64(
+ image: ffi::GhosttyKittyGraphicsImage,
+ data: ffi::GhosttyKittyGraphicsImageData,
+) -> Result<u64, Error> {
+ let mut out = 0u64;
+ unsafe {
+ ffi::ghostty_kitty_graphics_image_get(image, data, (&mut out as *mut u64).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_transmission_payload(
+ image: ffi::GhosttyKittyGraphicsImage,
+ decoded_format: KittyImageFormat,
+ forwarding: bool,
+) -> Result<(KittyImageFormat, *const u8, usize), Error> {
+ if !forwarding {
+ let (ptr, len) = kitty_image_data_ptr_len(image)?;
+ return Ok((decoded_format, ptr, len));
+ }
+ let mut ptr_out: *const u8 = ptr::null();
+ let result = unsafe {
+ ffi::ghostty_kitty_graphics_image_get(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR,
+ (&mut ptr_out as *mut *const u8).cast(),
+ )
+ };
+ if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
+ let (ptr, len) = kitty_image_data_ptr_len(image)?;
+ return Ok((decoded_format, ptr, len));
+ }
+ result.into_result()?;
+ let mut len = 0usize;
+ unsafe {
+ ffi::ghostty_kitty_graphics_image_get(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN,
+ (&mut len as *mut usize).cast(),
+ )
+ .into_result()?;
+ }
+ Ok((KittyImageFormat::Png, ptr_out, len))
+}
+
+fn kitty_image_data_ptr_len(
+ image: ffi::GhosttyKittyGraphicsImage,
+) -> Result<(*const u8, usize), Error> {
+ 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()?;
+ }
+ Ok((ptr_out, len))
+}
+
+fn kitty_image_data_from_ptr(ptr_out: *const u8, len: usize) -> Vec<u8> {
+ if ptr_out.is_null() || len == 0 {
+ return Vec::new();
+ }
+ unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec()
+}
+
+// Hashes the full payload. Callers cache the result per image id and only
+// recompute it when the image's transmit time changes.
+fn kitty_image_fingerprint(
+ ptr_out: *const u8,
+ len: usize,
+ image_width: u32,
+ image_height: u32,
+ format: KittyImageFormat,
+) -> u64 {
+ let mut hasher = DefaultHasher::new();
+ len.hash(&mut hasher);
+ image_width.hash(&mut hasher);
+ image_height.hash(&mut hasher);
+ format.hash(&mut hasher);
+ if ptr_out.is_null() || len == 0 {
+ return hasher.finish();
+ }
+
+ let data = unsafe { slice::from_raw_parts(ptr_out, len) };
+ data.hash(&mut hasher);
+ hasher.finish()
+}
+
+fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<String>, Error> {
+ let mut required = 0usize;
+ let result =
+ unsafe { ffi::ghostty_grid_ref_hyperlink_uri(grid_ref, ptr::null_mut(), 0, &mut required) };
+ if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
+ result.into_result()?;
+ }
+ if required == 0 {
+ return Ok(None);
+ }
+ let mut buffer = vec![0u8; required];
+ unsafe {
+ ffi::ghostty_grid_ref_hyperlink_uri(
+ grid_ref,
+ buffer.as_mut_ptr(),
+ buffer.len(),
+ &mut required,
+ )
+ .into_result()?;
+ }
+ buffer.truncate(required);
+ Ok(Some(String::from_utf8_lossy(&buffer).into_owned()))
+}
+
+pub struct RenderState {
+ raw: ffi::GhosttyRenderState,
+}
+
+impl RenderState {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ // SAFETY: valid out pointer and null allocator use default allocator.
+ unsafe {
+ ffi::ghostty_render_state_new(ptr::null(), &mut raw).into_result()?;
+ }
+ Ok(Self { raw })
+ }
+
+ pub fn update(&mut self, terminal: &Terminal) -> Result<(), Error> {
+ // SAFETY: both handles are valid for the duration of the call.
+ unsafe { ffi::ghostty_render_state_update(self.raw, terminal.raw()).into_result() }
+ }
+
+ pub fn cols(&self) -> Result<u16, Error> {
+ self.get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_COLS)
+ }
+
+ pub fn rows(&self) -> Result<u16, Error> {
+ self.get_u16(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_ROWS)
+ }
+
+ pub fn dirty(&self) -> Result<Dirty, Error> {
+ let mut out = ffi::GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FALSE;
+ // SAFETY: out points to the matching enum storage for the requested data kind.
+ unsafe {
+ ffi::ghostty_render_state_get(
+ self.raw,
+ ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_DIRTY,
+ (&mut out as *mut ffi::GhosttyRenderStateDirty).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(Dirty::from_raw(out))
+ }
+
+ pub fn cursor(&self) -> Result<RenderCursor, Error> {
+ let mut cursor = ffi::GhosttyRenderStateCursor {
+ size: mem::size_of::<ffi::GhosttyRenderStateCursor>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_render_state_get(
+ self.raw,
+ ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR,
+ (&mut cursor as *mut ffi::GhosttyRenderStateCursor).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(RenderCursor {
+ // Upstream leaves these fields undefined when the cursor is offscreen.
+ viewport: if cursor.viewport_has_value {
+ Some(CursorViewport {
+ x: cursor.viewport_x,
+ y: cursor.viewport_y,
+ wide_tail: cursor.wide_tail,
+ })
+ } else {
+ None
+ },
+ visible: cursor.visible,
+ blinking: cursor.blinking,
+ visual_style: CursorVisualStyle::from_raw(cursor.visual_style),
+ })
+ }
+
+ pub fn colors(&self) -> Result<RenderColors, Error> {
+ let mut colors = ffi::GhosttyRenderStateColors {
+ size: mem::size_of::<ffi::GhosttyRenderStateColors>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_render_state_get(
+ self.raw,
+ ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_COLORS,
+ (&mut colors as *mut ffi::GhosttyRenderStateColors).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(RenderColors {
+ background: colors.background.into(),
+ foreground: colors.foreground.into(),
+ palette: colors.palette.map(Into::into),
+ })
+ }
+
+ pub fn clean(&mut self) -> Result<(), Error> {
+ unsafe { ffi::ghostty_render_state_clean(self.raw).into_result() }
+ }
+
+ pub fn set_dirty(&mut self, dirty: Dirty) -> Result<(), Error> {
+ let value = dirty.as_raw();
+ // SAFETY: value pointer matches the expected option type.
+ unsafe {
+ ffi::ghostty_render_state_set(
+ self.raw,
+ ffi::GhosttyRenderStateOption_GHOSTTY_RENDER_STATE_OPTION_DIRTY,
+ (&value as *const ffi::GhosttyRenderStateDirty).cast(),
+ )
+ .into_result()
+ }
+ }
+
+ pub fn populate_row_iterator<'a>(
+ &'a self,
+ iterator: &'a mut RowIterator,
+ ) -> Result<RowIter<'a>, Error> {
+ // SAFETY: iterator raw handle is valid and will not outlive self.
+ unsafe {
+ ffi::ghostty_render_state_get(
+ self.raw,
+ ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR,
+ (&mut iterator.raw as *mut ffi::GhosttyRenderStateRowIterator).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(RowIter {
+ iterator,
+ _state: PhantomData,
+ })
+ }
+
+ fn get_u16(&self, data: ffi::GhosttyRenderStateData) -> Result<u16, Error> {
+ let mut out = 0u16;
+ // SAFETY: out points to a u16 matching the requested render-state data type.
+ unsafe {
+ ffi::ghostty_render_state_get(self.raw, data, (&mut out as *mut u16).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+}
+
+// SAFETY: these opaque handles are only used behind external synchronization in pane runtime.
+unsafe impl Send for RenderState {}
+
+impl Drop for RenderState {
+ fn drop(&mut self) {
+ // SAFETY: freeing a null or live handle is allowed by the C API.
+ unsafe {
+ ffi::ghostty_render_state_free(self.raw);
+ }
+ }
+}
+
+pub struct KeyEvent {
+ raw: ffi::GhosttyKeyEvent,
+}
+
+impl KeyEvent {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ unsafe { ffi::ghostty_key_event_new(ptr::null(), &mut raw).into_result()? };
+ Ok(Self { raw })
+ }
+
+ pub fn set_action(&mut self, action: ffi::GhosttyKeyAction) {
+ unsafe { ffi::ghostty_key_event_set_action(self.raw, action) }
+ }
+
+ pub fn set_key(&mut self, key: ffi::GhosttyKey) {
+ unsafe { ffi::ghostty_key_event_set_key(self.raw, key) }
+ }
+
+ pub fn set_mods(&mut self, mods: u16) {
+ unsafe { ffi::ghostty_key_event_set_mods(self.raw, mods) }
+ }
+
+ pub fn set_utf8(&mut self, text: &str) {
+ unsafe {
+ ffi::ghostty_key_event_set_utf8(self.raw, text.as_ptr().cast::<c_char>(), text.len())
+ }
+ }
+
+ pub fn set_unshifted_codepoint(&mut self, codepoint: u32) {
+ unsafe { ffi::ghostty_key_event_set_unshifted_codepoint(self.raw, codepoint) }
+ }
+}
+
+impl Drop for KeyEvent {
+ fn drop(&mut self) {
+ unsafe { ffi::ghostty_key_event_free(self.raw) }
+ }
+}
+
+pub struct KeyEncoder {
+ raw: ffi::GhosttyKeyEncoder,
+}
+
+impl KeyEncoder {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ unsafe { ffi::ghostty_key_encoder_new(ptr::null(), &mut raw).into_result()? };
+ Ok(Self { raw })
+ }
+
+ pub fn set_from_terminal(&mut self, terminal: &Terminal) {
+ unsafe { ffi::ghostty_key_encoder_setopt_from_terminal(self.raw, terminal.raw()) }
+ }
+
+ pub fn encode(&mut self, event: &KeyEvent) -> Result<Vec<u8>, Error> {
+ encode_with_retry(|buf, len, out_len| unsafe {
+ ffi::ghostty_key_encoder_encode(self.raw, event.raw, buf, len, out_len)
+ })
+ }
+}
+
+// SAFETY: the opaque encoder handle is only used behind external synchronization in pane runtime.
+unsafe impl Send for KeyEncoder {}
+
+impl Drop for KeyEncoder {
+ fn drop(&mut self) {
+ unsafe { ffi::ghostty_key_encoder_free(self.raw) }
+ }
+}
+
+pub struct MouseEvent {
+ raw: ffi::GhosttyMouseEvent,
+}
+
+impl MouseEvent {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ unsafe { ffi::ghostty_mouse_event_new(ptr::null(), &mut raw).into_result()? };
+ Ok(Self { raw })
+ }
+
+ pub fn set_action(&mut self, action: ffi::GhosttyMouseAction) {
+ unsafe { ffi::ghostty_mouse_event_set_action(self.raw, action) }
+ }
+
+ pub fn set_button(&mut self, button: ffi::GhosttyMouseButton) {
+ unsafe { ffi::ghostty_mouse_event_set_button(self.raw, button) }
+ }
+
+ pub fn clear_button(&mut self) {
+ unsafe { ffi::ghostty_mouse_event_clear_button(self.raw) }
+ }
+
+ pub fn set_mods(&mut self, mods: u16) {
+ unsafe { ffi::ghostty_mouse_event_set_mods(self.raw, mods) }
+ }
+
+ pub fn set_position(&mut self, x: f32, y: f32) {
+ unsafe {
+ ffi::ghostty_mouse_event_set_position(self.raw, ffi::GhosttyMousePosition { x, y })
+ }
+ }
+}
+
+impl Drop for MouseEvent {
+ fn drop(&mut self) {
+ unsafe { ffi::ghostty_mouse_event_free(self.raw) }
+ }
+}
+
+pub struct MouseEncoder {
+ raw: ffi::GhosttyMouseEncoder,
+}
+
+impl MouseEncoder {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ unsafe { ffi::ghostty_mouse_encoder_new(ptr::null(), &mut raw).into_result()? };
+ Ok(Self { raw })
+ }
+
+ pub fn set_from_terminal(&mut self, terminal: &Terminal) {
+ unsafe { ffi::ghostty_mouse_encoder_setopt_from_terminal(self.raw, terminal.raw()) }
+ }
+
+ pub fn set_size(
+ &mut self,
+ screen_width: u32,
+ screen_height: u32,
+ cell_width: u32,
+ cell_height: u32,
+ ) {
+ let size = ffi::GhosttyMouseEncoderSize {
+ size: std::mem::size_of::<ffi::GhosttyMouseEncoderSize>(),
+ screen_width,
+ screen_height,
+ cell_width,
+ cell_height,
+ padding_top: 0,
+ padding_bottom: 0,
+ padding_right: 0,
+ padding_left: 0,
+ };
+ unsafe {
+ ffi::ghostty_mouse_encoder_setopt(
+ self.raw,
+ ffi::GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_SIZE,
+ (&size as *const ffi::GhosttyMouseEncoderSize).cast(),
+ )
+ }
+ }
+
+ pub fn set_format(&mut self, format: ffi::GhosttyMouseFormat) {
+ unsafe {
+ ffi::ghostty_mouse_encoder_setopt(
+ self.raw,
+ ffi::GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_FORMAT,
+ (&format as *const ffi::GhosttyMouseFormat).cast(),
+ )
+ }
+ }
+
+ pub fn encode(&mut self, event: &MouseEvent) -> Result<Vec<u8>, Error> {
+ encode_with_retry(|buf, len, out_len| unsafe {
+ ffi::ghostty_mouse_encoder_encode(self.raw, event.raw, buf, len, out_len)
+ })
+ }
+}
+
+impl Drop for MouseEncoder {
+ fn drop(&mut self) {
+ unsafe { ffi::ghostty_mouse_encoder_free(self.raw) }
+ }
+}
+
+fn encode_with_retry(
+ mut encode: impl FnMut(*mut c_char, usize, *mut usize) -> ffi::GhosttyResult,
+) -> Result<Vec<u8>, Error> {
+ let mut required = 0usize;
+ let result = encode(ptr::null_mut(), 0, &mut required);
+ if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
+ result.into_result()?;
+ }
+ let mut buffer = vec![0u8; required.max(16)];
+ let mut written = 0usize;
+ encode(
+ buffer.as_mut_ptr().cast::<c_char>(),
+ buffer.len(),
+ &mut written,
+ )
+ .into_result()?;
+ buffer.truncate(written);
+ Ok(buffer)
+}
+
+pub struct RowIterator {
+ raw: ffi::GhosttyRenderStateRowIterator,
+}
+
+impl RowIterator {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ // SAFETY: valid out pointer and null allocator use default allocator.
+ unsafe {
+ ffi::ghostty_render_state_row_iterator_new(ptr::null(), &mut raw).into_result()?;
+ }
+ Ok(Self { raw })
+ }
+}
+
+// SAFETY: these opaque handles are only used behind external synchronization in pane runtime.
+unsafe impl Send for RowIterator {}
+
+impl Drop for RowIterator {
+ fn drop(&mut self) {
+ // SAFETY: freeing a null or live handle is allowed by the C API.
+ unsafe {
+ ffi::ghostty_render_state_row_iterator_free(self.raw);
+ }
+ }
+}
+
+pub struct RowIter<'a> {
+ iterator: &'a mut RowIterator,
+ _state: PhantomData<&'a RenderState>,
+}
+
+impl<'a> RowIter<'a> {
+ // Advances a native cursor; rows are read in place, so this is not an Iterator.
+ #[allow(clippy::should_implement_trait)]
+ pub fn next(&mut self) -> bool {
+ // SAFETY: iterator handle is valid while self is alive.
+ unsafe { ffi::ghostty_render_state_row_iterator_next(self.iterator.raw) }
+ }
+
+ pub fn next_dirty(&mut self) -> Option<u16> {
+ let mut y = 0u16;
+ // SAFETY: iterator is live and y matches the viewport-coordinate output type.
+ let advanced =
+ unsafe { ffi::ghostty_render_state_row_iterator_next_dirty(self.iterator.raw, &mut y) };
+ advanced.then_some(y)
+ }
+
+ pub fn dirty(&self) -> Result<bool, Error> {
+ let mut dirty = false;
+ // SAFETY: dirty output matches requested row data type.
+ unsafe {
+ ffi::ghostty_render_state_row_get(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY,
+ (&mut dirty as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(dirty)
+ }
+
+ #[cfg(windows)]
+ pub fn wrap_state(&self) -> Result<(bool, bool), Error> {
+ let mut row = 0;
+ // SAFETY: row output matches requested row data type.
+ unsafe {
+ ffi::ghostty_render_state_row_get(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_RAW,
+ (&mut row as *mut ffi::GhosttyRow).cast(),
+ )
+ .into_result()?;
+ }
+ let mut soft_wrapped = false;
+ // SAFETY: wrap output matches requested row data type.
+ unsafe {
+ ffi::ghostty_row_get(
+ row,
+ ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP,
+ (&mut soft_wrapped as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ let mut wrap_continuation = false;
+ // SAFETY: wrap continuation output matches requested row data type.
+ unsafe {
+ ffi::ghostty_row_get(
+ row,
+ ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP_CONTINUATION,
+ (&mut wrap_continuation as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ Ok((soft_wrapped, wrap_continuation))
+ }
+
+ pub fn clear_dirty(&mut self) -> Result<(), Error> {
+ self.set_dirty(false)
+ }
+
+ pub fn set_dirty(&mut self, dirty: bool) -> Result<(), Error> {
+ // SAFETY: dirty pointer matches the expected row option type.
+ unsafe {
+ ffi::ghostty_render_state_row_set(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowOption_GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY,
+ (&dirty as *const bool).cast(),
+ )
+ .into_result()
+ }
+ }
+
+ pub fn selection(&self) -> Result<Option<RowSelection>, Error> {
+ let mut selection = ffi::GhosttyRenderStateRowSelection {
+ size: mem::size_of::<ffi::GhosttyRenderStateRowSelection>(),
+ ..Default::default()
+ };
+ let result = unsafe {
+ ffi::ghostty_render_state_row_get(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION,
+ (&mut selection as *mut ffi::GhosttyRenderStateRowSelection).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => Ok(Some(RowSelection {
+ start_x: selection.start_x,
+ end_x: selection.end_x,
+ })),
+ ffi::GhosttyResult_GHOSTTY_NO_VALUE => Ok(None),
+ other => Err(Error(other)),
+ }
+ }
+
+ pub fn populate_cells<'b>(
+ &'b mut self,
+ cells: &'b mut RowCells,
+ ) -> Result<RowCellIter<'b>, Error> {
+ // SAFETY: cells raw handle is valid and will not outlive the current row borrow.
+ unsafe {
+ ffi::ghostty_render_state_row_get(
+ self.iterator.raw,
+ ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_CELLS,
+ (&mut cells.raw as *mut ffi::GhosttyRenderStateRowCells).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(RowCellIter { cells })
+ }
+}
+
+pub struct RowCells {
+ raw: ffi::GhosttyRenderStateRowCells,
+}
+
+impl RowCells {
+ pub fn new() -> Result<Self, Error> {
+ let mut raw = ptr::null_mut();
+ // SAFETY: valid out pointer and null allocator use default allocator.
+ unsafe {
+ ffi::ghostty_render_state_row_cells_new(ptr::null(), &mut raw).into_result()?;
+ }
+ Ok(Self { raw })
+ }
+}
+
+// SAFETY: these opaque handles are only used behind external synchronization in pane runtime.
+unsafe impl Send for RowCells {}
+
+impl Drop for RowCells {
+ fn drop(&mut self) {
+ // SAFETY: freeing a null or live handle is allowed by the C API.
+ unsafe {
+ ffi::ghostty_render_state_row_cells_free(self.raw);
+ }
+ }
+}
+
+pub struct RowCellIter<'a> {
+ cells: &'a mut RowCells,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CellBasicData {
+ pub wide: CellWide,
+ pub has_hyperlink: bool,
+ pub has_styling: bool,
+ pub style: CellStyle,
+}
+
+impl Default for CellBasicData {
+ fn default() -> Self {
+ Self {
+ wide: CellWide::Narrow,
+ has_hyperlink: false,
+ has_styling: false,
+ style: CellStyle::default(),
+ }
+ }
+}
+
+impl<'a> RowCellIter<'a> {
+ #[allow(clippy::should_implement_trait)]
+ pub fn next(&mut self) -> bool {
+ // SAFETY: cells handle is valid while self is alive.
+ unsafe { ffi::ghostty_render_state_row_cells_next(self.cells.raw) }
+ }
+
+ pub fn select(&mut self, x: u16) -> Result<(), Error> {
+ unsafe { ffi::ghostty_render_state_row_cells_select(self.cells.raw, x).into_result() }
+ }
+
+ fn raw_cell(&self) -> Result<ffi::GhosttyCell, Error> {
+ let mut raw = ffi::GhosttyCell::default();
+ unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW,
+ (&mut raw as *mut ffi::GhosttyCell).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(raw)
+ }
+
+ pub fn basic_data(&self) -> Result<CellBasicData, Error> {
+ let mut raw = ffi::GhosttyCell::default();
+ let mut style = ffi::GhosttyStyle {
+ size: mem::size_of::<ffi::GhosttyStyle>(),
+ ..Default::default()
+ };
+ let mut has_styling = false;
+ let row_keys = [
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING,
+ ];
+ let mut row_values = [
+ (&mut raw as *mut ffi::GhosttyCell).cast::<c_void>(),
+ (&mut style as *mut ffi::GhosttyStyle).cast::<c_void>(),
+ (&mut has_styling as *mut bool).cast::<c_void>(),
+ ];
+ let mut written = 0usize;
+ unsafe {
+ ffi::ghostty_render_state_row_cells_get_multi(
+ self.cells.raw,
+ row_keys.len(),
+ row_keys.as_ptr(),
+ row_values.as_mut_ptr(),
+ &mut written,
+ )
+ .into_result()?;
+ }
+
+ let mut wide = ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW;
+ let mut has_hyperlink = false;
+ let cell_keys = [
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_WIDE,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_HYPERLINK,
+ ];
+ let mut cell_values = [
+ (&mut wide as *mut ffi::GhosttyCellWide).cast::<c_void>(),
+ (&mut has_hyperlink as *mut bool).cast::<c_void>(),
+ ];
+ unsafe {
+ ffi::ghostty_cell_get_multi(
+ raw,
+ cell_keys.len(),
+ cell_keys.as_ptr(),
+ cell_values.as_mut_ptr(),
+ &mut written,
+ )
+ .into_result()?;
+ }
+
+ Ok(CellBasicData {
+ wide: CellWide::from_raw(wide),
+ has_hyperlink,
+ has_styling,
+ style: style.into(),
+ })
+ }
+
+ pub fn wide(&self) -> Result<CellWide, Error> {
+ let raw = self.raw_cell()?;
+ let mut wide = ffi::GhosttyCellWide_GHOSTTY_CELL_WIDE_NARROW;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_WIDE,
+ (&mut wide as *mut ffi::GhosttyCellWide).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(CellWide::from_raw(wide))
+ }
+
+ pub fn has_hyperlink(&self) -> Result<bool, Error> {
+ let raw = self.raw_cell()?;
+ let mut has_hyperlink = false;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_HYPERLINK,
+ (&mut has_hyperlink as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(has_hyperlink)
+ }
+
+ pub fn style(&self) -> Result<CellStyle, Error> {
+ let mut style = ffi::GhosttyStyle {
+ size: mem::size_of::<ffi::GhosttyStyle>(),
+ ..Default::default()
+ };
+ unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE,
+ (&mut style as *mut ffi::GhosttyStyle).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(style.into())
+ }
+
+ pub fn content_bg_color(&self) -> Result<Option<CellColor>, Error> {
+ let raw = self.raw_cell()?;
+ let mut tag = ffi::GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_CODEPOINT;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_CONTENT_TAG,
+ (&mut tag as *mut ffi::GhosttyCellContentTag).cast(),
+ )
+ .into_result()?;
+ }
+
+ match tag {
+ ffi::GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE => {
+ let mut index = 0u8;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_COLOR_PALETTE,
+ (&mut index as *mut u8).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(Some(CellColor::Palette(index)))
+ }
+ ffi::GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_BG_COLOR_RGB => {
+ let mut color = ffi::GhosttyColorRgb::default();
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_COLOR_RGB,
+ (&mut color as *mut ffi::GhosttyColorRgb).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(Some(CellColor::Rgb(color.into())))
+ }
+ _ => Ok(None),
+ }
+ }
+
+ pub fn fg_color(&self) -> Result<Option<RgbColor>, Error> {
+ let mut color = ffi::GhosttyColorRgb::default();
+ let result = unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR,
+ (&mut color as *mut ffi::GhosttyColorRgb).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => Ok(Some(color.into())),
+ ffi::GhosttyResult_GHOSTTY_INVALID_VALUE => Ok(None),
+ other => Err(Error(other)),
+ }
+ }
+
+ pub fn bg_color(&self) -> Result<Option<RgbColor>, Error> {
+ let mut color = ffi::GhosttyColorRgb::default();
+ let result = unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR,
+ (&mut color as *mut ffi::GhosttyColorRgb).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => Ok(Some(color.into())),
+ ffi::GhosttyResult_GHOSTTY_INVALID_VALUE => Ok(None),
+ other => Err(Error(other)),
+ }
+ }
+
+ fn raw_cell_text_into(&self, text: &mut String) -> Result<(), Error> {
+ let raw = self.raw_cell()?;
+ let mut has_text = false;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_TEXT,
+ (&mut has_text as *mut bool).cast(),
+ )
+ .into_result()?;
+ }
+ if !has_text {
+ return Ok(());
+ }
+
+ let mut codepoint = 0u32;
+ unsafe {
+ ffi::ghostty_cell_get(
+ raw,
+ ffi::GhosttyCellData_GHOSTTY_CELL_DATA_CODEPOINT,
+ (&mut codepoint as *mut u32).cast(),
+ )
+ .into_result()?;
+ }
+ if let Some(ch) = char::from_u32(codepoint) {
+ text.push(ch);
+ }
+ Ok(())
+ }
+
+ pub fn grapheme_text(&self) -> Result<String, Error> {
+ let mut bytes = Vec::new();
+ let mut text = String::new();
+ self.grapheme_text_into(&mut bytes, &mut text)?;
+ Ok(text)
+ }
+
+ pub fn grapheme_text_into(&self, bytes: &mut Vec<u8>, text: &mut String) -> Result<(), Error> {
+ text.clear();
+ bytes.clear();
+
+ let mut buffer = ffi::GhosttyBuffer {
+ ptr: ptr::null_mut(),
+ cap: 0,
+ len: 0,
+ };
+ let result = unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8,
+ (&mut buffer as *mut ffi::GhosttyBuffer).cast(),
+ )
+ };
+ match result {
+ ffi::GhosttyResult_GHOSTTY_SUCCESS if buffer.len == 0 => {
+ return self.raw_cell_text_into(text);
+ }
+ ffi::GhosttyResult_GHOSTTY_SUCCESS => {
+ return Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE));
+ }
+ ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE => {}
+ other => return Err(Error(other)),
+ }
+
+ if buffer.len == 0 {
+ return self.raw_cell_text_into(text);
+ }
+ bytes.resize(buffer.len, 0);
+ let mut buffer = ffi::GhosttyBuffer {
+ ptr: bytes.as_mut_ptr(),
+ cap: bytes.len(),
+ len: 0,
+ };
+ unsafe {
+ ffi::ghostty_render_state_row_cells_get(
+ self.cells.raw,
+ ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8,
+ (&mut buffer as *mut ffi::GhosttyBuffer).cast(),
+ )
+ .into_result()?;
+ }
+ if buffer.len > bytes.len() {
+ return Err(Error(ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE));
+ }
+ bytes.truncate(buffer.len);
+ match std::str::from_utf8(bytes) {
+ Ok(value) => text.push_str(value),
+ Err(_) => text.push_str(&String::from_utf8_lossy(bytes)),
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ fn write_numbered_lines(terminal: &mut Terminal, count: usize) {
+ for i in 0..count {
+ terminal.write(format!("{i:06}\r\n").as_bytes());
+ }
+ }
+
+ fn write_padded_lines(terminal: &mut Terminal, count: usize, width: usize) {
+ let line = format!("{}\r\n", "x".repeat(width));
+ terminal.write(line.repeat(count).as_bytes());
+ }
+
+ fn first_rendered_row_text(terminal: &Terminal) -> String {
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(terminal).unwrap();
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ let mut row_cells = RowCells::new().unwrap();
+ let mut bytes = Vec::new();
+ let mut cell_text = String::new();
+ let mut row_text = String::new();
+
+ assert!(rows.next());
+ let mut cells = rows.populate_cells(&mut row_cells).unwrap();
+ while cells.next() {
+ cells
+ .grapheme_text_into(&mut bytes, &mut cell_text)
+ .unwrap();
+ row_text.push_str(&cell_text);
+ }
+ row_text.trim_end().to_owned()
+ }
+
+ fn build_info_bool(data: ffi::GhosttyBuildInfo) -> bool {
+ let mut out = false;
+ unsafe {
+ ffi::ghostty_build_info(data, (&mut out as *mut bool).cast())
+ .into_result()
+ .unwrap();
+ }
+ out
+ }
+
+ fn build_info_optimize() -> ffi::GhosttyOptimizeMode {
+ let mut out = ffi::GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_DEBUG;
+ unsafe {
+ ffi::ghostty_build_info(
+ ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_OPTIMIZE,
+ (&mut out as *mut ffi::GhosttyOptimizeMode).cast(),
+ )
+ .into_result()
+ .unwrap();
+ }
+ out
+ }
+
+ #[test]
+ fn incremental_compression_preserves_cold_scrollback() {
+ let mut terminal = Terminal::new(80, 24, 20_000_000).unwrap();
+ let initial_activity = terminal.compression_activity().unwrap();
+ let suffix = "x".repeat(66);
+ for line in 1..=10_000 {
+ terminal.write(format!("{line:05} {suffix}\r\n").as_bytes());
+ }
+ assert_ne!(terminal.compression_activity().unwrap(), initial_activity);
+
+ let mut complete = false;
+ for _ in 0..10_000 {
+ match terminal.compress_incremental().unwrap() {
+ TerminalCompressionResult::Unsupported => return,
+ TerminalCompressionResult::Pending => {}
+ TerminalCompressionResult::Complete => {
+ complete = true;
+ break;
+ }
+ }
+ }
+ assert!(complete, "incremental compression did not converge");
+
+ let oldest = terminal.read_text_screen((0, 0), (79, 0), false).unwrap();
+ assert!(oldest.starts_with("00001 "));
+ let last_row = terminal.total_rows().unwrap() as u32 - 1;
+ let newest = terminal
+ .read_text_screen((0, last_row - 1), (79, last_row), false)
+ .unwrap();
+ assert!(newest.contains("10000"));
+ }
+
+ #[test]
+ fn kitty_png_replacement_rejects_invalid_payload_without_placing_it() {
+ use base64::Engine as _;
+ let mut png = Vec::new();
+ {
+ let mut encoder = png::Encoder::new(&mut png, 2, 1);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header().unwrap();
+ writer
+ .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 255])
+ .unwrap();
+ }
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ let upload = |payload: &[u8]| {
+ format!(
+ "\x1b[H\x1b_Ga=T,f=100,i=7,p=3,c=2,r=1,q=2;{}\x1b\\",
+ base64::engine::general_purpose::STANDARD.encode(payload)
+ )
+ };
+ terminal.write(upload(&png).as_bytes());
+ let before = terminal.kitty_image_placements().unwrap();
+ assert_eq!(before.len(), 1);
+ assert_eq!((before[0].image_width, before[0].image_height), (2, 1));
+ assert_eq!(before[0].data, [255, 0, 0, 255, 0, 0, 255, 255]);
+ // The parser retires the old placement, but never places corrupt data.
+ terminal.write(upload(&png[..png.len() / 2]).as_bytes());
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn kitty_file_image_survives_source_mutation_and_unlink() {
+ use base64::Engine as _;
+ let path = std::path::PathBuf::from(format!(
+ "/var/tmp/herdr-kitty-source-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::write(&path, [255, 0, 0, 255]).unwrap();
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ terminal.write(
+ format!(
+ "\x1b_Ga=T,f=32,t=f,i=7,p=3,s=1,v=1,c=1,r=1,q=2;{}\x1b\\",
+ base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap())
+ )
+ .as_bytes(),
+ );
+ std::fs::write(&path, [0, 0, 255, 255]).unwrap();
+ std::fs::remove_file(&path).unwrap();
+ let placements = terminal.kitty_image_placements().unwrap();
+ assert_eq!(placements.len(), 1);
+ assert_eq!(placements[0].data, [255, 0, 0, 255]);
+ }
+
+ #[test]
+ fn kitty_image_fingerprint_covers_full_payload() {
+ let mut data = vec![1u8; 4096 * 4];
+ let original =
+ kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
+
+ data[4096 + 123] = 2;
+ let changed_outside_sampled_windows =
+ kitty_image_fingerprint(data.as_ptr(), data.len(), 100, 50, KittyImageFormat::Png);
+ assert_ne!(original, changed_outside_sampled_windows);
+ }
+
+ #[test]
+ fn kitty_image_fingerprint_refreshes_on_retransmission() {
+ let mut terminal = Terminal::new(10, 5, 0).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 first = terminal
+ .kitty_image_placements_with_data_filter(|_| true)
+ .unwrap();
+ assert_eq!(first.len(), 1);
+ let first_generation = terminal
+ .kitty_fingerprints
+ .lock()
+ .unwrap()
+ .get(&7)
+ .unwrap()
+ .generation;
+ assert_ne!(first_generation, 0);
+
+ // Retransmission removes old placements; transmit-and-display replaces it.
+ terminal.write(b"\x1b[H\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;AAAAAA==\x1b\\");
+ let second = terminal
+ .kitty_image_placements_with_data_filter(|_| true)
+ .unwrap();
+ assert_eq!(second.len(), 1);
+ assert_ne!(first[0].data_fingerprint, second[0].data_fingerprint);
+ let second_generation = terminal
+ .kitty_fingerprints
+ .lock()
+ .unwrap()
+ .get(&7)
+ .unwrap()
+ .generation;
+ assert_ne!(first_generation, second_generation);
+
+ // No retransmission, so the fingerprint and generation stay stable.
+ let third = terminal
+ .kitty_image_placements_with_data_filter(|_| true)
+ .unwrap();
+ assert_eq!(second[0].data_fingerprint, third[0].data_fingerprint);
+ assert_eq!(
+ terminal
+ .kitty_fingerprints
+ .lock()
+ .unwrap()
+ .get(&7)
+ .unwrap()
+ .generation,
+ second_generation
+ );
+ }
+
+ #[test]
+ fn kitty_storage_generation_skips_only_proven_empty_storage() {
+ let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ terminal.resize(10, 5, 8, 16).unwrap();
+
+ assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0);
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+
+ terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\");
+ let transmitted = terminal.kitty_graphics_generation().unwrap();
+ assert_ne!(transmitted, 0);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+ assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted));
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
+
+ terminal.write(b"plain text");
+ assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted);
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+
+ terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\");
+ let placed = terminal.kitty_graphics_generation().unwrap();
+ assert_ne!(placed, transmitted);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
+ assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
+
+ terminal.resize(10, 5, 12, 24).unwrap();
+ assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
+ assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
+
+ write_numbered_lines(&mut terminal, 20);
+ assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+ assert_ne!(terminal.kitty_empty_generation.get(), Some(placed));
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
+ terminal.scroll_viewport_row(0);
+ assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
+
+ // Delete-visible must preserve images in scrollback, even while viewed.
+ terminal.write(b"\x1b_Ga=d,d=A\x1b\\");
+ assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
+ assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
+ terminal.write(b"\x1b_Ga=d,d=I,i=1\x1b\\");
+ let deleted = terminal.kitty_graphics_generation().unwrap();
+ assert_ne!(deleted, placed);
+ assert!(terminal.kitty_graphics_may_have_placements().unwrap());
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+ assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted));
+ assert!(!terminal.kitty_graphics_may_have_placements().unwrap());
+ }
+
+ #[test]
+ fn build_info_contract_matches_expected_vendored_features() {
+ let _simd = build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_SIMD);
+ let _tmux_control_mode =
+ build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE);
+ let _kitty_graphics =
+ build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_KITTY_GRAPHICS);
+
+ let optimize = build_info_optimize();
+ assert!(matches!(
+ optimize,
+ ffi::GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_DEBUG
+ | ffi::GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_SAFE
+ | ffi::GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_SMALL
+ | ffi::GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_FAST
+ ));
+ }
+
+ #[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 kitty_graphics_local_media_are_enabled() {
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+
+ assert!(terminal
+ .get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE)
+ .unwrap());
+ let mut temp_dir = ffi::GhosttyString::default();
+ unsafe {
+ ffi::ghostty_terminal_get(
+ terminal.raw,
+ ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE,
+ (&mut temp_dir as *mut ffi::GhosttyString).cast(),
+ )
+ .into_result()
+ .unwrap();
+ assert_eq!(
+ borrowed_bytes(temp_dir).unwrap(),
+ std::env::temp_dir().to_string_lossy().as_bytes()
+ );
+ }
+ assert!(terminal
+ .get_bool(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM)
+ .unwrap());
+ }
+
+ #[test]
+ fn kitty_graphics_file_medium_rgba_placement_is_queryable() {
+ use base64::Engine;
+
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-kitty-file-medium-test-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("pixel.rgba");
+ std::fs::write(&path, [255, 0, 0, 255]).unwrap();
+
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ terminal.resize(10, 5, 8, 16).unwrap();
+ let encoded_path =
+ base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes());
+ let command =
+ format!("\x1b_Ga=T,f=32,t=f,i=9,p=4,s=1,v=1,c=10,r=5,q=2;{encoded_path}\x1b\\");
+ terminal.write(command.as_bytes());
+ terminal.write(b"\x1b_Ga=p,U=1,i=9,c=10,r=5\x1b\\");
+
+ let placements = terminal.kitty_image_placements().unwrap();
+ assert_eq!(placements.len(), 1);
+ assert_eq!(placements[0].image_id, 9);
+ assert_eq!(placements[0].placement_id, 4);
+ 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);
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn kitty_graphics_file_upload_can_be_placed_later() {
+ use base64::Engine as _;
+
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-kitty-file-upload-test-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("pixel.rgba");
+ std::fs::write(&path, [255, 0, 0, 255]).unwrap();
+
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ terminal.resize(10, 5, 8, 16).unwrap();
+ let payload =
+ base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap().as_bytes());
+ let upload = format!("\x1b7\x1b_Ga=t,f=32,s=1,v=1,i=10,q=0,t=f;{payload}\x1b\\\x1b8");
+ terminal.write(upload.as_bytes());
+ assert!(terminal.kitty_image_placements().unwrap().is_empty());
+
+ terminal.write(b"\x1b_Ga=p,i=10,p=5,c=10,r=5,C=1,q=2\x1b\\");
+ let placements = terminal.kitty_image_placements().unwrap();
+ assert_eq!(placements.len(), 1);
+ assert_eq!(placements[0].image_id, 10);
+ assert_eq!(placements[0].placement_id, 5);
+ 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]);
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn kitty_graphics_unicode_placeholder_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_Gq=2,a=t,t=d,f=32,s=1,v=1,i=1193046,m=0;/wAA/w==\x1b\\");
+ terminal.write(b"\x1b_Gq=2,a=p,U=1,i=1193046,c=2,r=1\x1b\\");
+ terminal.write("\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\u{10eeee}\u{0305}\u{030d}\x1b[0m".as_bytes());
+
+ let placements = terminal.kitty_image_placements().unwrap();
+ assert_eq!(placements.len(), 1);
+ assert_eq!(placements[0].image_id, 1193046);
+ assert_ne!(placements[0].placement_id, 0);
+ 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.viewport_col, 2);
+ assert_eq!(placements[0].render.viewport_row, 1);
+ assert_eq!(placements[0].render.grid_cols, 2);
+ assert_eq!(placements[0].render.grid_rows, 1);
+ }
+
+ #[test]
+ fn unicode_width_helpers_match_terminal_layout_rules() {
+ assert_eq!(unicode_codepoint_width('A' as u32), 1);
+ assert_eq!(unicode_codepoint_width('\u{301}' as u32), 0);
+ assert_eq!(unicode_codepoint_width('η•Œ' as u32), 2);
+ assert_eq!(unicode_codepoint_width(0x11_0000), 1);
+
+ let cases: &[(&[u32], usize, u8)] = &[
+ (&[], 0, 0),
+ (&['e' as u32, '\u{301}' as u32], 2, 1),
+ (&['⚠' as u32, '\u{fe0f}' as u32], 2, 2),
+ (&['⚠' as u32, '\u{fe0e}' as u32], 2, 1),
+ (&['πŸ‡§' as u32, 'πŸ‡·' as u32], 2, 2),
+ (&['πŸ‘' as u32, '🏽' as u32], 2, 2),
+ (
+ &[
+ 'πŸ‘¨' as u32,
+ '\u{200d}' as u32,
+ 'πŸ‘©' as u32,
+ '\u{200d}' as u32,
+ 'πŸ‘§' as u32,
+ ],
+ 5,
+ 2,
+ ),
+ (&[0x11_0000, 'A' as u32], 1, 1),
+ ];
+ for &(codepoints, consumed, width) in cases {
+ assert_eq!(unicode_grapheme_width(codepoints), (consumed, width));
+ }
+ }
+
+ #[test]
+ fn focus_encoding_matches_expected_sequences() {
+ assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I");
+ assert_eq!(encode_focus(FocusEvent::Lost).unwrap(), b"\x1b[O");
+ }
+
+ #[test]
+ fn terminal_callbacks_report_pty_responses_and_pwd_changes() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
+ let sink = responses.clone();
+ terminal
+ .set_write_pty_callback(move |bytes| sink.lock().unwrap().extend_from_slice(bytes))
+ .unwrap();
+
+ terminal.write(b"\x1b[6n\x1b]7;file:///tmp/herdr\x07");
+
+ let output = responses.lock().unwrap().clone();
+ assert!(!output.is_empty());
+ assert!(String::from_utf8_lossy(&output).contains("R"));
+ assert_eq!(terminal.take_pwd_changes(), [b"file:///tmp/herdr".to_vec()]);
+ }
+
+ #[test]
+ fn key_and_mouse_encoders_follow_terminal_state() {
+ let mut terminal = Terminal::new(80, 24, 0).unwrap();
+ terminal.mode_set(1, true).unwrap();
+ terminal.write(b"\x1b[>1u\x1b[?1000h\x1b[?1006h");
+
+ assert!(terminal.mode_get(1).unwrap());
+ assert_eq!(terminal.kitty_keyboard_flags().unwrap(), 1);
+ assert!(terminal.mouse_tracking_enabled().unwrap());
+
+ let mut key_encoder = KeyEncoder::new().unwrap();
+ key_encoder.set_from_terminal(&terminal);
+ let mut key_event = KeyEvent::new().unwrap();
+ key_event.set_action(ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS);
+ key_event.set_key(KEY_A);
+ key_event.set_mods(MOD_CTRL | MOD_SHIFT);
+ key_event.set_utf8("A");
+ key_event.set_unshifted_codepoint('a' as u32);
+ let encoded_key = key_encoder.encode(&key_event).unwrap();
+ assert_eq!(encoded_key, b"\x1b[97;6u");
+
+ let mut mouse_encoder = MouseEncoder::new().unwrap();
+ mouse_encoder.set_from_terminal(&terminal);
+ mouse_encoder.set_size(80, 24, 1, 1);
+ let mut mouse_event = MouseEvent::new().unwrap();
+ mouse_event.set_action(ffi::GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_PRESS);
+ mouse_event.set_button(ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_LEFT);
+ mouse_event.set_position(0.0, 0.0);
+ let encoded_mouse = mouse_encoder.encode(&mouse_event).unwrap();
+ assert_eq!(encoded_mouse, b"\x1b[<0;1;1M");
+ }
+
+ #[test]
+ fn modify_other_keys_query_tracks_mode_two() {
+ let mut terminal = Terminal::new(80, 24, 0).unwrap();
+
+ assert!(!terminal.modify_other_keys_enabled().unwrap());
+ terminal.write(b"\x1b[>4;2m");
+ assert!(terminal.modify_other_keys_enabled().unwrap());
+ terminal.write(b"\x1b[>4;0m");
+ assert!(!terminal.modify_other_keys_enabled().unwrap());
+ }
+
+ #[test]
+ fn link_target_includes_offscreen_wrapped_rows() {
+ let url = "https://example.com/abcdefghijklmnopqrstuv";
+ let mut terminal = Terminal::new(20, 2, 1024 * 1024).unwrap();
+ terminal.write(url.as_bytes());
+ let expected = LinkTarget::Text {
+ text: url.to_owned(),
+ clicked_byte: 20,
+ };
+ assert_eq!(terminal.viewport_link_target(0, 0).unwrap(), Some(expected));
+ terminal.scroll_viewport_row(0);
+ assert_eq!(
+ terminal.viewport_link_target(0, 0).unwrap(),
+ Some(LinkTarget::Text {
+ text: url.to_owned(),
+ clicked_byte: 0
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_prefers_explicit_uri_and_does_not_join_hard_lines() {
+ let mut terminal = Terminal::new(20, 4, 1024).unwrap();
+ terminal.write(b"\x1b]8;;https://example.com/real\x1b\\label\x1b]8;;\x1b\\\r\nhttps://example.com/\r\ntail");
+ assert_eq!(
+ terminal.viewport_link_target(2, 0).unwrap(),
+ Some(LinkTarget::Uri("https://example.com/real".into()))
+ );
+ assert_eq!(
+ terminal.viewport_link_target(1, 2).unwrap(),
+ Some(LinkTarget::Text {
+ text: "tail".into(),
+ clicked_byte: 1
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_stops_at_hard_line_when_clicking_last_column() {
+ let mut terminal = Terminal::new(20, 3, 1024).unwrap();
+ terminal.write(b"https://example.com/x\r\nnot-part-of-url");
+ assert_eq!(
+ terminal.viewport_link_target(19, 0).unwrap(),
+ Some(LinkTarget::Text {
+ text: "https://example.com/x".into(),
+ clicked_byte: 19
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_wide_explicit_label_works_on_both_cell_halves() {
+ let mut terminal = Terminal::new(20, 2, 1024).unwrap();
+ terminal.write("\x1b]8;;https://example.com/\x1b\\θ·―εΎ„\x1b]8;;\x1b\\".as_bytes());
+ for col in 0..4 {
+ assert_eq!(
+ terminal.viewport_link_target(col, 0).unwrap(),
+ Some(LinkTarget::Uri("https://example.com/".into()))
+ );
+ }
+ }
+
+ #[test]
+ fn link_target_selection_budget_is_shared_across_both_directions() {
+ let mut terminal = Terminal::new(20, 2, 1024).unwrap();
+ terminal.write(b" abc ");
+ let boundaries = [u32::from(b' ')];
+ let options = ffi::GhosttyTerminalSelectWordOptions {
+ size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(),
+ ref_: terminal.grid_ref(ghostty_viewport_point(2, 0)).unwrap(),
+ boundary_codepoints: boundaries.as_ptr(),
+ boundary_codepoints_len: boundaries.len(),
+ };
+ for budget in 0..=5 {
+ let mut selection = ffi::GhosttySelection::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_select_word_bounded(
+ terminal.raw,
+ &options,
+ budget,
+ &mut selection,
+ )
+ };
+ if budget < 5 {
+ assert_eq!(
+ result,
+ ffi::GhosttyResult_GHOSTTY_NO_VALUE,
+ "budget={budget}"
+ );
+ } else {
+ result.into_result().unwrap();
+ assert_eq!(
+ terminal
+ .format_selection(&selection, FormatterFormat::Plain, true, false)
+ .unwrap(),
+ "abc"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn link_target_rejects_oversized_token_without_returning_a_prefix() {
+ let mut terminal = Terminal::new(100, 4, 16 * 1024 * 1024).unwrap();
+ let text = format!("https://example.com/{}", "a".repeat(1_000_000));
+ terminal.write(text.as_bytes());
+ let started = std::time::Instant::now();
+ let target = terminal.viewport_link_target(0, 0).unwrap();
+ eprintln!("million-byte token lookup: {:?}", started.elapsed());
+ assert!(target.is_none());
+ }
+
+ #[test]
+ fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() {
+ let mut terminal = Terminal::new(5, 3, 0).unwrap();
+ terminal.write("1ABCD2EFGH3IJKL".as_bytes());
+
+ let text = terminal.read_text_viewport((0, 1), (2, 2), false).unwrap();
+ assert_eq!(text, "2EFGH3IJ");
+ }
+
+ #[test]
+ fn terminal_extracts_viewport_hyperlink_uri() {
+ let mut terminal = Terminal::new(20, 3, 0).unwrap();
+ terminal.write(b"\x1b]8;;https://example.com\x1b\\Link\x1b]8;;\x1b\\");
+
+ assert_eq!(
+ terminal.viewport_hyperlink_uri(0, 0).unwrap().as_deref(),
+ Some("https://example.com")
+ );
+ assert_eq!(terminal.viewport_hyperlink_uri(4, 0).unwrap(), None);
+ }
+
+ #[test]
+ fn terminal_read_text_viewport_handles_wide_chars() {
+ let mut terminal = Terminal::new(5, 3, 0).unwrap();
+ terminal.write("1A⚑".as_bytes());
+
+ let full = terminal.read_text_viewport((0, 0), (3, 0), false).unwrap();
+ assert_eq!(full, "1A⚑");
+
+ let through_wide_head = terminal.read_text_viewport((0, 0), (2, 0), false).unwrap();
+ assert_eq!(through_wide_head, "1A⚑");
+
+ let wide_only = terminal.read_text_viewport((3, 0), (3, 0), false).unwrap();
+ assert_eq!(wide_only, "⚑");
+ }
+
+ #[test]
+ fn zero_max_scrollback_disables_history() {
+ let mut terminal = Terminal::new(80, 3, 0).unwrap();
+ write_numbered_lines(&mut terminal, 3000);
+ assert_eq!(terminal.scrollback_rows().unwrap(), 0);
+ }
+
+ #[test]
+ fn max_scrollback_limit_bytes_retains_more_history_for_larger_limits() {
+ let mut small = Terminal::new(80, 3, 1_000_000).unwrap();
+ let mut large = Terminal::new(80, 3, 10_000_000).unwrap();
+
+ write_padded_lines(&mut small, 1_250, 70);
+ write_padded_lines(&mut large, 1_250, 70);
+
+ let small_scrollback = small.scrollback_rows().unwrap();
+ let large_scrollback = large.scrollback_rows().unwrap();
+
+ assert!(
+ large_scrollback > small_scrollback,
+ "expected larger byte limit to retain more history, got small={small_scrollback}, large={large_scrollback}"
+ );
+ }
+
+ #[test]
+ fn large_negative_scroll_delta_reaches_top_of_scrollback() {
+ let mut terminal = Terminal::new(80, 3, 1_000_000).unwrap();
+ write_numbered_lines(&mut terminal, 1000);
+
+ let before = terminal.scrollbar().unwrap();
+ assert!(before.total > before.len);
+
+ terminal.scroll_viewport_bottom();
+ terminal.scroll_viewport_delta(-10_000);
+
+ let after = terminal.scrollbar().unwrap();
+ assert_eq!(after.offset, 0);
+ assert_eq!(after.len, before.len);
+ }
+
+ #[test]
+ fn absolute_scroll_row_round_trips_and_clamps() {
+ let mut terminal = Terminal::new(80, 3, 1_000_000).unwrap();
+ write_numbered_lines(&mut terminal, 1000);
+
+ let before = terminal.scrollbar().unwrap();
+ let max_row = before.total.saturating_sub(before.len);
+ assert!(max_row > 0);
+
+ for row in [0, max_row / 2, max_row, usize::MAX] {
+ terminal.scroll_viewport_row(row);
+ let after = terminal.scrollbar().unwrap();
+ assert_eq!(after.offset, row.min(max_row));
+ assert_eq!(after.len, before.len);
+ }
+ }
+
+ #[test]
+ fn deep_scrollback_resize_preserves_unicode_and_hyperlinks() {
+ use std::fmt::Write as _;
+
+ let mut terminal = Terminal::new(20, 5, 100_000_000).unwrap();
+ let mut input = String::from("\x1b]8;;https://example.com\x1b\\FIRST πŸ‡§πŸ‡·\x1b]8;;\x1b\\\r\n");
+ for line in 0..70_000 {
+ writeln!(input, "{line:05} πŸ‘¨β€πŸ‘©β€πŸ‘§").unwrap();
+ }
+ terminal.write(input.as_bytes());
+
+ assert!(terminal.scrollback_rows().unwrap() > u16::MAX as usize);
+ terminal.scroll_viewport_delta(-100_000);
+ assert_eq!(terminal.scrollbar().unwrap().offset, 0);
+ assert!(terminal
+ .read_text_viewport((0, 0), (19, 0), false)
+ .unwrap()
+ .starts_with("FIRST πŸ‡§πŸ‡·"));
+ assert_eq!(
+ terminal.viewport_hyperlink_uri(0, 0).unwrap().as_deref(),
+ Some("https://example.com")
+ );
+
+ terminal.resize(10, 5, 8, 16).unwrap();
+ terminal.scroll_viewport_delta(-100_000);
+ let metrics = terminal.scrollbar().unwrap();
+ assert_eq!(metrics.offset, 0);
+ assert_eq!(metrics.len, 5);
+ assert!(terminal
+ .read_text_viewport((0, 0), (9, 0), false)
+ .unwrap()
+ .starts_with("FIRST πŸ‡§πŸ‡·"));
+ assert_eq!(
+ terminal.viewport_hyperlink_uri(0, 0).unwrap().as_deref(),
+ Some("https://example.com")
+ );
+ }
+
+ #[test]
+ fn raw_resize_preserves_content_without_replaying_terminal_effects() {
+ let mut terminal = Terminal::new(20, 6, 100_000).unwrap();
+ terminal.write(b"header\r\n\x1b[6;1Htail\x1b[6;18H");
+ for (cols, rows) in [(10, 3), (30, 8), (8, 4), (20, 6)] {
+ terminal.resize(cols, rows, 8, 16).unwrap();
+ terminal.write(b"X");
+ let text = terminal
+ .read_text_screen(
+ (0, 0),
+ (cols - 1, terminal.total_rows().unwrap() as u32 - 1),
+ false,
+ )
+ .unwrap();
+ assert!(
+ text.contains("tail"),
+ "lost tail after {cols}x{rows}: {text:?}"
+ );
+ assert_eq!(text.matches("tail").count(), 1);
+ assert!(terminal.cursor_y().unwrap() < rows);
+ }
+ terminal.write(b"\x1b[2J\x1b[H");
+ terminal.resize(12, 3, 8, 16).unwrap();
+ assert!(terminal
+ .read_text_viewport((0, 0), (11, 2), false)
+ .unwrap()
+ .trim()
+ .is_empty());
+ }
+
+ #[test]
+ fn clipboard_queries_never_disclose_contents_and_split_writes_complete_once() {
+ for suffix in [b"\x07".as_slice(), b"\x1b\\".as_slice()] {
+ let bytes = [b"\x1b]52;c;YQBi".as_slice(), suffix].concat();
+ for split in 0..=bytes.len() {
+ let mut terminal = Terminal::new(10, 3, 0).unwrap();
+ let replies = Arc::new(Mutex::new(Vec::new()));
+ let captured = Arc::clone(&replies);
+ terminal
+ .set_write_pty_callback(move |bytes| {
+ captured.lock().unwrap().extend_from_slice(bytes)
+ })
+ .unwrap();
+ terminal.write(&bytes[..split]);
+ terminal.write(&bytes[split..]);
+ assert_eq!(terminal.take_clipboard_writes(), vec![b"a\0b".to_vec()]);
+ terminal.write(b"\x1b]52;c;?\x07\x1b]52;p;YQBi\x07");
+ assert!(terminal.take_clipboard_writes().is_empty());
+ assert!(replies.lock().unwrap().is_empty());
+ }
+ }
+ }
+
+ #[test]
+ fn active_screen_and_cursor_visibility_contract() {
+ let mut terminal = Terminal::new(12, 3, 0).unwrap();
+ let mut render_state = RenderState::new().unwrap();
+
+ terminal.write(b"primary");
+ assert_eq!(terminal.active_screen().unwrap(), ActiveScreen::Primary);
+ assert_eq!(
+ terminal.read_text_viewport((0, 0), (6, 0), false).unwrap(),
+ "primary"
+ );
+
+ render_state.update(&terminal).unwrap();
+ assert!(render_state.cursor().unwrap().visible);
+ terminal.write(b"\x1b[?25l");
+ render_state.update(&terminal).unwrap();
+ assert!(!render_state.cursor().unwrap().visible);
+
+ terminal.write(b"\x1b[?1049h\x1b[HALT");
+ assert_eq!(terminal.active_screen().unwrap(), ActiveScreen::Alternate);
+ assert_eq!(
+ terminal.read_text_viewport((0, 0), (2, 0), false).unwrap(),
+ "ALT"
+ );
+
+ terminal.write(b"\x1b[?1049l");
+ assert_eq!(terminal.active_screen().unwrap(), ActiveScreen::Primary);
+ assert_eq!(
+ terminal.read_text_viewport((0, 0), (6, 0), false).unwrap(),
+ "primary"
+ );
+ }
+
+ #[test]
+ fn terminal_and_render_state_smoke_test() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ assert_eq!(terminal.cols().unwrap(), 8);
+ assert_eq!(terminal.rows().unwrap(), 3);
+
+ terminal.write(b"hello\r\nworld");
+
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(&terminal).unwrap();
+ assert_eq!(render_state.cols().unwrap(), 8);
+ assert_eq!(render_state.rows().unwrap(), 3);
+ assert_ne!(render_state.dirty().unwrap(), Dirty::Clean);
+
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut row_iter = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ let mut row_cells = RowCells::new().unwrap();
+
+ let mut found_hello = false;
+ let mut found_world = false;
+ let mut row_index = 0usize;
+ while row_iter.next() {
+ let _ = row_iter.dirty().unwrap();
+ let mut cells = row_iter.populate_cells(&mut row_cells).unwrap();
+ let mut line = String::new();
+ while cells.next() {
+ let text = cells.grapheme_text().unwrap();
+ if text.is_empty() {
+ line.push(' ');
+ } else {
+ line.push_str(&text);
+ }
+ }
+ let trimmed = line.trim_end().to_string();
+ if row_index == 0 {
+ found_hello = trimmed.starts_with("hello");
+ }
+ if row_index == 1 {
+ found_world = trimmed.starts_with("world");
+ }
+ row_index += 1;
+ }
+
+ assert!(found_hello);
+ assert!(found_world);
+
+ render_state.set_dirty(Dirty::Clean).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
+ }
+
+ #[test]
+ fn render_cells_preserve_issue_453_unicode_payload_exactly() {
+ const PAYLOAD: &str = "README πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ πŸ§‘β€πŸ’» βœ… ⚑ ζΌ’ε­— cafΓ© é πŸ³οΈβ€πŸŒˆ πŸš€";
+ let mut terminal = Terminal::new(80, 3, 100).unwrap();
+ assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap());
+ terminal.write(format!("{PAYLOAD}\r\n").as_bytes());
+
+ assert_eq!(first_rendered_row_text(&terminal), PAYLOAD);
+ }
+
+ #[test]
+ fn grapheme_cluster_mode_is_default_and_survives_full_reset() {
+ let mut terminal = Terminal::new(80, 3, 100).unwrap();
+ assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap());
+ terminal.mode_set(MODE_GRAPHEME_CLUSTER, false).unwrap();
+ assert!(!terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap());
+
+ terminal.write(b"\x1bc");
+
+ assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap());
+ }
+
+ #[test]
+ fn screen_text_rows_preserve_wrap_and_grapheme_cells() {
+ let mut terminal = Terminal::new(5, 3, 100).unwrap();
+ terminal.write("abcdef\r\nη•Œe\u{301}".as_bytes());
+
+ let rows = terminal.screen_text_rows().unwrap();
+
+ assert_eq!(rows.len(), 3);
+ assert!(rows[0].soft_wrapped);
+ assert!(!rows[0].wrap_continuation);
+ assert!(!rows[1].soft_wrapped);
+ assert!(rows[1].wrap_continuation);
+ assert!(!rows[2].wrap_continuation);
+ assert_eq!(rows[2].cells[0].wide, CellWide::Wide);
+ assert_eq!(rows[2].cells[0].graphemes, vec!['η•Œ' as u32]);
+ assert_eq!(rows[2].cells[1].wide, CellWide::SpacerTail);
+ assert_eq!(rows[2].cells[2].graphemes, vec!['e' as u32, 0x301]);
+ }
+
+ #[test]
+ fn render_state_row_dirty_can_be_cleared_independently() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ let mut render_state = RenderState::new().unwrap();
+
+ render_state.update(&terminal).unwrap();
+ {
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ while rows.next() {
+ rows.clear_dirty().unwrap();
+ assert!(!rows.dirty().unwrap());
+ }
+ }
+ {
+ let mut iterator = RowIterator::new().unwrap();
+ let mut rows = render_state.populate_row_iterator(&mut iterator).unwrap();
+ assert_eq!(rows.next_dirty(), Some(0));
+ assert_eq!(rows.next_dirty(), Some(1));
+ assert_eq!(rows.next_dirty(), Some(2));
+ assert_eq!(rows.next_dirty(), None);
+ }
+ render_state.set_dirty(Dirty::Clean).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
+ {
+ let mut iterator = RowIterator::new().unwrap();
+ let mut rows = render_state.populate_row_iterator(&mut iterator).unwrap();
+ assert_eq!(rows.next_dirty(), None);
+ }
+
+ terminal.write(b"A");
+ render_state.update(&terminal).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Partial);
+
+ let mut dirty_rows = 0usize;
+ {
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ while let Some(y) = rows.next_dirty() {
+ assert_eq!(y, 0);
+ assert!(rows.dirty().unwrap());
+ dirty_rows += 1;
+ rows.clear_dirty().unwrap();
+ assert!(!rows.dirty().unwrap());
+ }
+ }
+ assert_eq!(dirty_rows, 1);
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Partial);
+
+ render_state.set_dirty(Dirty::Clean).unwrap();
+ assert_eq!(render_state.dirty().unwrap(), Dirty::Clean);
+ }
+
+ #[test]
+ fn row_selection_returns_none_without_selection() {
+ let terminal = Terminal::new(8, 3, 100).unwrap();
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(&terminal).unwrap();
+
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ assert!(rows.next());
+ assert_eq!(rows.selection().unwrap(), None);
+ }
+
+ fn test_clipboard_content(mime: &[u8], data: &[u8]) -> ffi::GhosttyClipboardContent {
+ ffi::GhosttyClipboardContent {
+ mime: ffi::GhosttyString {
+ ptr: mime.as_ptr(),
+ len: mime.len(),
+ },
+ data: ffi::GhosttyString {
+ ptr: data.as_ptr(),
+ len: data.len(),
+ },
+ }
+ }
+
+ fn invoke_clipboard_callback(
+ terminal: &mut Terminal,
+ contents: &[ffi::GhosttyClipboardContent],
+ size: usize,
+ ) -> ffi::GhosttyClipboardWriteResult {
+ unsafe extern "C" fn reply(
+ request: *const ffi::GhosttyClipboardWrite,
+ response: *const ffi::GhosttyClipboardWriteReply,
+ ) {
+ let state = unsafe {
+ &mut *((*request).ctx as *mut (ffi::GhosttyClipboardWriteResult, bool, usize))
+ };
+ state.0 = unsafe { (*response).result };
+ state.1 = unsafe { (*response).remember };
+ state.2 += 1;
+ }
+ let mut response = (
+ ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA,
+ false,
+ 0usize,
+ );
+ let request = ffi::GhosttyClipboardWrite {
+ size,
+ location: ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
+ contents: contents.as_ptr(),
+ contents_len: contents.len(),
+ ctx: (&mut response as *mut (ffi::GhosttyClipboardWriteResult, bool, usize)).cast(),
+ reply: Some(reply),
+ ..Default::default()
+ };
+ // SAFETY: the request, reply state and borrowed content live through this call.
+ unsafe {
+ clipboard_write_trampoline(
+ terminal.raw,
+ (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(),
+ &request,
+ );
+ }
+ assert_eq!(
+ response.2,
+ usize::from(size >= mem::size_of::<ffi::GhosttyClipboardWrite>())
+ );
+ assert!(!response.1, "migration must not create clipboard grants");
+ response.0
+ }
+
+ #[test]
+ fn clipboard_callback_ignores_clear_and_rejects_unsupported_writes() {
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ let full_size = std::mem::size_of::<ffi::GhosttyClipboardWrite>();
+ let success = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
+ let unsupported =
+ ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
+ let invalid = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
+
+ assert_eq!(
+ invoke_clipboard_callback(&mut terminal, &[], full_size),
+ success
+ );
+ assert!(terminal.take_clipboard_writes().is_empty());
+
+ let empty = test_clipboard_content(b"text/plain", b"");
+ assert_eq!(
+ invoke_clipboard_callback(&mut terminal, &[empty], full_size),
+ unsupported
+ );
+ let text = test_clipboard_content(b"text/plain", b"text");
+ let image = test_clipboard_content(b"image/png", b"image");
+ assert_eq!(
+ invoke_clipboard_callback(&mut terminal, &[text, image], full_size),
+ unsupported
+ );
+
+ let oversized = vec![b'x'; MAX_CLIPBOARD_BYTES + 1];
+ let oversized = test_clipboard_content(b"text/plain", &oversized);
+ assert_eq!(
+ invoke_clipboard_callback(&mut terminal, &[oversized], full_size),
+ invalid
+ );
+ assert_eq!(
+ invoke_clipboard_callback(&mut terminal, &[text], full_size - 1),
+ invalid
+ );
+ assert!(terminal.take_clipboard_writes().is_empty());
+ }
+
+ #[test]
+ fn libghostty_completes_osc52_writes_for_bel_and_st_without_queries() {
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.write(b"\x1b]52;c;aGVs");
+ assert!(terminal.take_clipboard_writes().is_empty());
+ terminal.write(b"bG8=\x07");
+ assert_eq!(terminal.take_clipboard_writes(), vec![b"hello".to_vec()]);
+
+ terminal.write(b"\x1b]52;c;d29ybGQ=\x1b\\");
+ assert_eq!(terminal.take_clipboard_writes(), vec![b"world".to_vec()]);
+
+ terminal.write(b"\x1b]52;c;?\x07");
+ assert!(terminal.take_clipboard_writes().is_empty());
+
+ terminal.write(b"\x1b]52;c;\x07");
+ assert!(terminal.take_clipboard_writes().is_empty());
+ }
+
+ #[test]
+ fn row_cell_basic_data_uses_batched_vendor_reads() {
+ let mut terminal = Terminal::new(8, 3, 100).unwrap();
+ terminal.write(b"\x1b[31mA\x1b[0m");
+
+ let mut render_state = RenderState::new().unwrap();
+ render_state.update(&terminal).unwrap();
+
+ let mut row_iterator = RowIterator::new().unwrap();
+ let mut rows = render_state
+ .populate_row_iterator(&mut row_iterator)
+ .unwrap();
+ assert!(rows.next());
+
+ let mut row_cells = RowCells::new().unwrap();
+ let mut cells = rows.populate_cells(&mut row_cells).unwrap();
+ assert!(cells.next());
+
+ let basic = cells.basic_data().unwrap();
+ assert_eq!(basic.wide, CellWide::Narrow);
+ assert!(basic.has_styling);
+ assert_eq!(basic.style.fg_color, Some(CellColor::Palette(1)));
+ assert!(!basic.has_hyperlink);
+ }
+}