diff options
Diffstat (limited to 'src/ghostty/mod.rs')
| -rw-r--r-- | src/ghostty/mod.rs | 4781 |
1 files changed, 0 insertions, 4781 deletions
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs deleted file mode 100644 index 43a70fb1..00000000 --- a/src/ghostty/mod.rs +++ /dev/null @@ -1,4781 +0,0 @@ -#![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; -mod native_source; - -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(crate) 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(crate) 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(crate) 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(crate) struct ScreenTextCell { - pub wide: CellWide, - pub graphemes: Vec<u32>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) 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(crate) enum LinkTarget { - Uri(String), - Text { text: String, clicked_byte: usize }, -} - -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: crate::pane::PANE_TERM.as_ptr().cast(), - len: crate::pane::PANE_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(crate) 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(crate) 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(crate) 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(crate) fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> { - self.screen_text_rows_range(0, usize::MAX) - } - - pub(crate) 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(crate) 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(crate) fn viewport_link_regions( - &self, - x: u16, - y: u32, - resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, - ) -> Result<Vec<crate::api::schema::PaneLinkRegion>, 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<crate::api::schema::PaneLinkRegion> = 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(crate::api::schema::PaneLinkRegion { - 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(crate) fn width_px(&self) -> Result<u32, Error> { - self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX) - } - - pub(crate) 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(crate) 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> { - 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> { - 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() { - 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 mut upload = Vec::new(); - crate::kitty_graphics::encode_kitty_regular_file( - &mut upload, - &[], - "a=t,f=32,s=1,v=1,i=10,q=0", - path.to_str().unwrap(), - ); - terminal.write(&upload); - 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); - } -} |
