diff options
Diffstat (limited to 'crates/ghostty-vt/src/lib.rs')
| -rw-r--r-- | crates/ghostty-vt/src/lib.rs | 4796 |
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Γ© eΜ π³οΈβπ π"; + 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); + } +} |
