diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
154 files changed, 26923 insertions, 4007 deletions
diff --git a/scripts/test_vendor_libghostty_vt.py b/scripts/test_vendor_libghostty_vt.py index 9dcdbb2d..ad5c8d5e 100644 --- a/scripts/test_vendor_libghostty_vt.py +++ b/scripts/test_vendor_libghostty_vt.py @@ -8,7 +8,11 @@ import unittest from pathlib import Path from unittest import mock -from scripts.vendor_libghostty_vt import ensure_dist_archive, parse_archive_root +from scripts.vendor_libghostty_vt import ( + ensure_dist_archive, + parse_archive_root, + require_clean_checkout, +) class VendorLibghosttyVtTests(unittest.TestCase): @@ -30,16 +34,44 @@ class VendorLibghosttyVtTests(unittest.TestCase): dist.mkdir(parents=True) (dist / "libghostty-vt-1.3.2-main-+deadbeef0.tar.gz").write_bytes(b"stale") + def git_output(command: list[str], **_kwargs: object) -> str: + if command[1] == "status": + return "" + return "0123456789abcdef\n" + with ( mock.patch("scripts.vendor_libghostty_vt.subprocess.run"), mock.patch( "scripts.vendor_libghostty_vt.subprocess.check_output", - return_value="0123456789abcdef\n", + side_effect=git_output, ), ): with self.assertRaisesRegex(FileNotFoundError, "HEAD 012345678"): ensure_dist_archive(repo) + def test_require_clean_checkout_rejects_tracked_and_untracked_changes(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + with mock.patch( + "scripts.vendor_libghostty_vt.subprocess.check_output", + return_value=" M src/terminal.zig\n?? local.patch\n", + ): + with self.assertRaisesRegex(ValueError, "refusing to vendor from dirty checkout"): + require_clean_checkout(repo) + + def test_ensure_dist_archive_rejects_checkout_dirtied_by_build(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + with ( + mock.patch("scripts.vendor_libghostty_vt.subprocess.run"), + mock.patch( + "scripts.vendor_libghostty_vt.subprocess.check_output", + side_effect=["", "0123456789abcdef\n", " M generated.txt\n"], + ), + ): + with self.assertRaisesRegex(ValueError, "refusing to vendor from dirty checkout"): + ensure_dist_archive(repo) + def test_vendored_tree_contains_required_upstream_files(self) -> None: root = Path(__file__).resolve().parent.parent / "vendor" / "libghostty-vt" required = [ diff --git a/scripts/vendor_libghostty_vt.py b/scripts/vendor_libghostty_vt.py index b493de03..7bd234d4 100644 --- a/scripts/vendor_libghostty_vt.py +++ b/scripts/vendor_libghostty_vt.py @@ -12,7 +12,6 @@ from pathlib import Path @dataclass class VendorMetadata: - source_repo: str source_commit: str dist_archive: str extracted_dir: str @@ -34,13 +33,25 @@ def git_head(repo: Path) -> str: return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() +def require_clean_checkout(repo: Path) -> None: + status = subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=all"], + cwd=repo, + text=True, + ).strip() + if status: + raise ValueError(f"refusing to vendor from dirty checkout {repo}:\n{status}") + + def ensure_dist_archive(source_repo: Path) -> Path: + require_clean_checkout(source_repo) head = git_head(source_repo)[:9] subprocess.run( ["zig", "build", "dist", "-Demit-lib-vt", "-Doptimize=ReleaseFast"], cwd=source_repo, check=True, ) + require_clean_checkout(source_repo) dist_dir = source_repo / "zig-out" / "dist" archives = sorted(dist_dir.glob(f"libghostty-vt-*+{head}.tar.gz")) if not archives: @@ -69,7 +80,6 @@ def vendor_libghostty_vt(source_repo: Path, destination: Path) -> VendorMetadata shutil.copytree(extracted, destination) return VendorMetadata( - source_repo=str(source_repo), source_commit=git_head(source_repo), dist_archive=archive.name, extracted_dir=root, diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index 73914a98..af743eaa 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -86,10 +86,107 @@ pub const GhosttyResult_GHOSTTY_OUT_OF_MEMORY: GhosttyResult = -1; pub const GhosttyResult_GHOSTTY_INVALID_VALUE: GhosttyResult = -2; #[doc = " Operation failed because the provided buffer was too small"] pub const GhosttyResult_GHOSTTY_OUT_OF_SPACE: GhosttyResult = -3; -#[doc = " Operation completed but the requested value was not present"] +#[doc = " The requested value has no value"] pub const GhosttyResult_GHOSTTY_NO_VALUE: GhosttyResult = -4; +#[doc = " The requested value has no value"] +pub const GhosttyResult_GHOSTTY_RESULT_MAX_VALUE: GhosttyResult = 2147483647; #[doc = " Result codes for libghostty-vt operations."] pub type GhosttyResult = ::std::os::raw::c_int; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a terminal instance.\n\n @ingroup terminal"] +pub type GhosttyTerminal = *mut GhosttyTerminalImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTrackedGridRefImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a tracked grid reference.\n\n A tracked grid reference is owned by the caller and must be freed with\n ghostty_tracked_grid_ref_free(). If the terminal that created it is freed\n first, the handle remains valid only for tracked-grid-ref APIs: it reports no\n value and can still be freed.\n\n @ingroup grid_ref"] +pub type GhosttyTrackedGridRef = *mut GhosttyTrackedGridRefImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a Kitty graphics image storage.\n\n Obtained via ghostty_terminal_get() with\n GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from\n the terminal and remains valid until the next mutating terminal call\n (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()).\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphics = *mut GhosttyKittyGraphicsImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsImageImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a Kitty graphics image.\n\n Obtained via ghostty_kitty_graphics_image() with an image ID. The\n pointer is borrowed from the storage and remains valid until the next\n mutating terminal call.\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsImage = *const GhosttyKittyGraphicsImageImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyGraphicsPlacementIteratorImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a Kitty graphics placement iterator.\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsPlacementIterator = *mut GhosttyKittyGraphicsPlacementIteratorImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyRenderStateImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a render state instance.\n\n @ingroup render"] +pub type GhosttyRenderState = *mut GhosttyRenderStateImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyRenderStateRowIteratorImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a render-state row iterator.\n\n @ingroup render"] +pub type GhosttyRenderStateRowIterator = *mut GhosttyRenderStateRowIteratorImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyRenderStateRowCellsImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to render-state row cells.\n\n @ingroup render"] +pub type GhosttyRenderStateRowCells = *mut GhosttyRenderStateRowCellsImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttySgrParserImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to an SGR parser instance.\n\n This handle represents an SGR (Select Graphic Rendition) parser that can\n be used to parse SGR sequences and extract individual text attributes.\n\n @ingroup sgr"] +pub type GhosttySgrParser = *mut GhosttySgrParserImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyFormatterImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a formatter instance.\n\n @ingroup formatter"] +pub type GhosttyFormatter = *mut GhosttyFormatterImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyOscParserImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to an OSC parser instance.\n\n This handle represents an OSC (Operating System Command) parser that can\n be used to parse the contents of OSC sequences.\n\n @ingroup osc"] +pub type GhosttyOscParser = *mut GhosttyOscParserImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyOscCommandImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to a single OSC command.\n\n This handle represents a parsed OSC (Operating System Command) command.\n The command can be queried for its type and associated data.\n\n @ingroup osc"] +pub type GhosttyOscCommand = *mut GhosttyOscCommandImpl; +#[doc = " Plain text (no escape sequences)."] +pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN: GhosttyFormatterFormat = 0; +#[doc = " VT sequences preserving colors, styles, URLs, etc."] +pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_VT: GhosttyFormatterFormat = 1; +#[doc = " HTML with inline styles."] +pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_HTML: GhosttyFormatterFormat = 2; +#[doc = " HTML with inline styles."] +pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_MAX_VALUE: GhosttyFormatterFormat = + 2147483647; +#[doc = " Terminal content output format.\n\n @ingroup formatter"] +pub type GhosttyFormatterFormat = ::std::os::raw::c_uint; #[doc = " A borrowed byte string (pointer + length).\n\n The memory is not owned by this struct. The pointer is only valid\n for the lifetime documented by the API that produces or consumes it."] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -106,6 +203,15 @@ const _: () = { ["Offset of field: GhosttyString::ptr"][::std::mem::offset_of!(GhosttyString, ptr) - 0usize]; ["Offset of field: GhosttyString::len"][::std::mem::offset_of!(GhosttyString, len) - 8usize]; }; +impl Default for GhosttyString { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} #[doc = " A caller-provided byte buffer.\n\n APIs that write to this type use `len` for the number of bytes written on\n GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE."] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -134,7 +240,44 @@ impl Default for GhosttyBuffer { } } } -impl Default for GhosttyString { +#[doc = " A surface-space position in pixels.\n\n This is not a terminal grid coordinate. It represents an x/y position in the\n rendered surface coordinate space, with (0, 0) at the top-left of the\n surface."] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttySurfacePosition { + #[doc = " X position in surface pixels."] + pub x: f64, + #[doc = " Y position in surface pixels."] + pub y: f64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySurfacePosition"][::std::mem::size_of::<GhosttySurfacePosition>() - 16usize]; + ["Alignment of GhosttySurfacePosition"] + [::std::mem::align_of::<GhosttySurfacePosition>() - 8usize]; + ["Offset of field: GhosttySurfacePosition::x"] + [::std::mem::offset_of!(GhosttySurfacePosition, x) - 0usize]; + ["Offset of field: GhosttySurfacePosition::y"] + [::std::mem::offset_of!(GhosttySurfacePosition, y) - 8usize]; +}; +#[doc = " A borrowed list of Unicode scalar values.\n\n Values are encoded as uint32_t scalar values. The memory is not owned by this\n struct. The pointer is only valid for the lifetime documented by the API that\n consumes or produces it.\n\n APIs may document special handling for NULL + len 0, such as “use defaults”."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyCodepoints { + #[doc = " Pointer to Unicode scalar values."] + pub ptr: *const u32, + #[doc = " Number of entries in ptr."] + pub len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyCodepoints"][::std::mem::size_of::<GhosttyCodepoints>() - 16usize]; + ["Alignment of GhosttyCodepoints"][::std::mem::align_of::<GhosttyCodepoints>() - 8usize]; + ["Offset of field: GhosttyCodepoints::ptr"] + [::std::mem::offset_of!(GhosttyCodepoints, ptr) - 0usize]; + ["Offset of field: GhosttyCodepoints::len"] + [::std::mem::offset_of!(GhosttyCodepoints, len) - 8usize]; +}; +impl Default for GhosttyCodepoints { fn default() -> Self { let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); unsafe { @@ -143,6 +286,10 @@ impl Default for GhosttyString { } } } +unsafe extern "C" { + #[doc = " Return a pointer to a null-terminated JSON string describing the\n layout of every C API struct for the current target.\n\n This is primarily useful for language bindings that can't easily\n set C struct fields and need to do so via byte offsets. For example,\n WebAssembly modules can't share struct definitions with the host.\n\n Example (abbreviated):\n @code{.json}\n {\n \"GhosttyMouseEncoderSize\": {\n \"size\": 40,\n \"align\": 8,\n \"fields\": {\n \"size\": { \"offset\": 0, \"size\": 8, \"type\": \"u64\" },\n \"screen_width\": { \"offset\": 8, \"size\": 4, \"type\": \"u32\" },\n \"screen_height\": { \"offset\": 12, \"size\": 4, \"type\": \"u32\" },\n \"cell_width\": { \"offset\": 16, \"size\": 4, \"type\": \"u32\" },\n \"cell_height\": { \"offset\": 20, \"size\": 4, \"type\": \"u32\" },\n \"padding_top\": { \"offset\": 24, \"size\": 4, \"type\": \"u32\" },\n \"padding_bottom\": { \"offset\": 28, \"size\": 4, \"type\": \"u32\" },\n \"padding_right\": { \"offset\": 32, \"size\": 4, \"type\": \"u32\" },\n \"padding_left\": { \"offset\": 36, \"size\": 4, \"type\": \"u32\" }\n }\n }\n }\n @endcode\n\n The returned pointer is valid for the lifetime of the process.\n\n @return Pointer to the null-terminated JSON string."] + pub fn ghostty_type_json() -> *const ::std::os::raw::c_char; +} #[doc = " Function table for custom memory allocator operations.\n\n This vtable defines the interface for a custom memory allocator. All\n function pointers must be valid and non-NULL.\n\n @ingroup allocator\n\n If you're not going to use a custom allocator, you can ignore all of\n this. All functions that take an allocator pointer allow NULL to use a\n default allocator.\n\n The interface is based on the Zig allocator interface. I'll say up front\n that it is easy to look at this interface and think \"wow, this is really\n overcomplicated\". The reason for this complexity is well thought out by\n the Zig folks, and it enables a diverse set of allocation strategies\n as shown by the Zig ecosystem. As a consolation, please note that many\n of the arguments are only needed for advanced use cases and can be\n safely ignored in simple implementations. For example, if you look at\n the Zig implementation of the libc allocator in `lib/std/heap.zig`\n (search for CAllocator), you'll see it is very simple.\n\n We chose to align with the Zig allocator interface because:\n\n 1. It is a proven interface that serves a wide variety of use cases\n in the real world via the Zig ecosystem. It's shown to work.\n\n 2. Our core implementation itself is Zig, and this lets us very\n cheaply and easily convert between C and Zig allocators.\n\n NOTE(mitchellh): In the future, we can have default implementations of\n resize/remap and allow those to be null."] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] @@ -242,6 +389,7 @@ pub const GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_DEBUG: GhosttyOptimizeMode = 0; pub const GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_SAFE: GhosttyOptimizeMode = 1; pub const GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_SMALL: GhosttyOptimizeMode = 2; pub const GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_RELEASE_FAST: GhosttyOptimizeMode = 3; +pub const GhosttyOptimizeMode_GHOSTTY_OPTIMIZE_MODE_MAX_VALUE: GhosttyOptimizeMode = 2147483647; #[doc = " Build optimization mode."] pub type GhosttyOptimizeMode = ::std::os::raw::c_uint; #[doc = " Invalid data type. Never results in any data extraction."] @@ -254,6 +402,20 @@ pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_KITTY_GRAPHICS: GhosttyBuildInfo = pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE: GhosttyBuildInfo = 3; #[doc = " The optimization mode the library was built with.\n\n Output type: GhosttyOptimizeMode *"] pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_OPTIMIZE: GhosttyBuildInfo = 4; +#[doc = " The full version string (e.g. \"1.2.3\" or \"1.2.3-dev+abcdef\").\n\n Output type: GhosttyString *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_STRING: GhosttyBuildInfo = 5; +#[doc = " The major version number.\n\n Output type: size_t *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_MAJOR: GhosttyBuildInfo = 6; +#[doc = " The minor version number.\n\n Output type: size_t *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_MINOR: GhosttyBuildInfo = 7; +#[doc = " The patch version number.\n\n Output type: size_t *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_PATCH: GhosttyBuildInfo = 8; +#[doc = " The pre metadata string (e.g. \"alpha\", \"beta\", \"dev\"). Has zero length if\n no pre metadata is present.\n\n Output type: GhosttyString *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_PRE: GhosttyBuildInfo = 9; +#[doc = " The build metadata string (e.g. commit hash). Has zero length if\n no build metadata is present.\n\n Output type: GhosttyString *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_VERSION_BUILD: GhosttyBuildInfo = 10; +#[doc = " The build metadata string (e.g. commit hash). Has zero length if\n no build metadata is present.\n\n Output type: GhosttyString *"] +pub const GhosttyBuildInfo_GHOSTTY_BUILD_INFO_MAX_VALUE: GhosttyBuildInfo = 2147483647; #[doc = " Build info data types that can be queried.\n\n Each variant documents the expected output pointer type."] pub type GhosttyBuildInfo = ::std::os::raw::c_uint; unsafe extern "C" { @@ -263,7 +425,7 @@ unsafe extern "C" { out: *mut ::std::os::raw::c_void, ) -> GhosttyResult; } -#[doc = " RGB color value.\n\n @ingroup sgr"] +#[doc = " RGB color value.\n\n @ingroup color"] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] pub struct GhosttyColorRgb { @@ -282,14 +444,116 @@ const _: () = { ["Offset of field: GhosttyColorRgb::g"][::std::mem::offset_of!(GhosttyColorRgb, g) - 1usize]; ["Offset of field: GhosttyColorRgb::b"][::std::mem::offset_of!(GhosttyColorRgb, b) - 2usize]; }; -#[doc = " Palette color index (0-255).\n\n @ingroup sgr"] +#[doc = " Palette color index (0-255).\n\n @ingroup color"] pub type GhosttyColorPaletteIndex = u8; +#[doc = " A 256-bit mask of palette indices.\n\n Index i is set iff `(bits[i >> 6] >> (i & 63)) & 1` is 1.\n The mask is typically initialized to zero and then populated with\n GHOSTTY_COLOR_PALETTE_MASK_SET().\n\n @code{.c}\n GhosttyColorPaletteMask mask = {0};\n GHOSTTY_COLOR_PALETTE_MASK_SET(&mask, 20);\n if (GHOSTTY_COLOR_PALETTE_MASK_IS_SET(&mask, 20)) {\n // Index 20 will be preserved.\n }\n @endcode\n\n @ingroup color"] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyColorPaletteMask { + pub bits: [u64; 4usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyColorPaletteMask"][::std::mem::size_of::<GhosttyColorPaletteMask>() - 32usize]; + ["Alignment of GhosttyColorPaletteMask"] + [::std::mem::align_of::<GhosttyColorPaletteMask>() - 8usize]; + ["Offset of field: GhosttyColorPaletteMask::bits"] + [::std::mem::offset_of!(GhosttyColorPaletteMask, bits) - 0usize]; +}; +#[doc = " An entry in Ghostty's X11 color name table.\n\n @ingroup color"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyColorX11Entry { + #[doc = " Null-terminated color name. NULL marks the end of the table."] + pub name: *const ::std::os::raw::c_char, + #[doc = " The RGB value of the color."] + pub color: GhosttyColorRgb, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyColorX11Entry"][::std::mem::size_of::<GhosttyColorX11Entry>() - 16usize]; + ["Alignment of GhosttyColorX11Entry"][::std::mem::align_of::<GhosttyColorX11Entry>() - 8usize]; + ["Offset of field: GhosttyColorX11Entry::name"] + [::std::mem::offset_of!(GhosttyColorX11Entry, name) - 0usize]; + ["Offset of field: GhosttyColorX11Entry::color"] + [::std::mem::offset_of!(GhosttyColorX11Entry, color) - 8usize]; +}; +impl Default for GhosttyColorX11Entry { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +unsafe extern "C" { + #[doc = " Get the RGB color components.\n\n This function extracts the individual red, green, and blue components\n from a GhosttyColorRgb value. Primarily useful in WebAssembly environments\n where accessing struct fields directly is difficult.\n\n @param color Pointer to the RGB color value\n @param r Pointer to store the red component (0-255)\n @param g Pointer to store the green component (0-255)\n @param b Pointer to store the blue component (0-255)\n\n @ingroup color"] + pub fn ghostty_color_rgb_get(color: *const GhosttyColorRgb, r: *mut u8, g: *mut u8, b: *mut u8); +} +unsafe extern "C" { + #[doc = " Parse an X11 color name.\n\n The color name is resolved from Ghostty's embedded rgb.txt table.\n Leading and trailing spaces and tabs are trimmed, and matching is\n ASCII case-insensitive. Hex values are not accepted by this function.\n\n @param name The color name bytes (must not be NULL)\n @param len The length of @p name in bytes\n @param[out] out The parsed RGB color\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if no color\n matches or @p name is NULL\n\n @ingroup color"] + pub fn ghostty_color_parse_x11( + name: *const ::std::os::raw::c_char, + len: usize, + out: *mut GhosttyColorRgb, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Parse a flexible Ghostty color value.\n\n Accepts Ghostty's terminal color syntax: X11 color names, hex colors\n in 3-, 6-, 9-, or 12-digit form (the leading # is optional for 3- and\n 6-digit values), and rgb:<red>/<green>/<blue> or\n rgbi:<red>/<green>/<blue> specifications. Leading and trailing spaces\n and tabs are trimmed.\n\n @param value The color value bytes (must not be NULL)\n @param len The length of @p value in bytes\n @param[out] out The parsed RGB color\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if parsing\n fails or @p value is NULL\n\n @ingroup color"] + pub fn ghostty_color_parse( + value: *const ::std::os::raw::c_char, + len: usize, + out: *mut GhosttyColorRgb, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Parse a Ghostty palette entry.\n\n Accepts Ghostty palette config syntax: N=COLOR. N is a palette index\n from 0 to 255 in decimal or in 0x, 0o, or 0b-prefixed form. Spaces and\n tabs around N and COLOR are ignored. COLOR accepts the same syntax as\n ghostty_color_parse().\n\n @param value The palette entry bytes (must not be NULL)\n @param len The length of @p value in bytes\n @param[out] out_index The parsed palette index\n @param[out] out_rgb The parsed RGB color\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE on any\n failure, including index overflow\n\n @ingroup color"] + pub fn ghostty_color_parse_palette_entry( + value: *const ::std::os::raw::c_char, + len: usize, + out_index: *mut u8, + out_rgb: *mut GhosttyColorRgb, + ) -> GhosttyResult; +} unsafe extern "C" { - #[doc = " Get the RGB color components.\n\n This function extracts the individual red, green, and blue components\n from a GhosttyColorRgb value. Primarily useful in WebAssembly environments\n where accessing struct fields directly is difficult.\n\n @param color The RGB color value\n @param r Pointer to store the red component (0-255)\n @param g Pointer to store the green component (0-255)\n @param b Pointer to store the blue component (0-255)\n\n @ingroup sgr"] - pub fn ghostty_color_rgb_get(color: GhosttyColorRgb, r: *mut u8, g: *mut u8, b: *mut u8); + #[doc = " Get Ghostty's built-in default 256-color palette.\n\n Writes exactly 256 entries: Ghostty's base16 defaults, the xterm\n 6x6x6 color cube, and the grayscale ramp.\n\n @param[out] out The output palette, an array of exactly 256\n GhosttyColorRgb values\n\n @ingroup color"] + pub fn ghostty_color_palette_default(out: *mut GhosttyColorRgb); +} +unsafe extern "C" { + #[doc = " Generate a 256-color palette from base colors.\n\n The base palette supplies indices 0-15, which are always preserved.\n If @p base is NULL, Ghostty's default palette is used. If @p skip is\n NULL, no extra indices are skipped. Set bits in @p skip preserve those\n indices from @p base. The 216-color cube at indices 16-231 is generated\n with trilinear CIELAB interpolation, and the grayscale ramp at indices\n 232-255 is interpolated from the background to the foreground.\n\n For light themes, @p harmonious controls whether the generated palette\n keeps the background-to-foreground orientation. When false, Ghostty\n swaps the light background and dark foreground so the cube and ramp run\n dark-to-light. The output palette may be the same pointer as @p base.\n\n @param base The base palette, an array of exactly 256 GhosttyColorRgb\n values, or NULL to use Ghostty's default palette\n @param skip The palette indices to preserve from @p base, or NULL for\n an empty mask\n @param bg The terminal background color (must not be NULL)\n @param fg The terminal foreground color (must not be NULL)\n @param harmonious Whether light themes keep background-to-foreground\n orientation\n @param[out] out The output palette, an array of exactly 256\n GhosttyColorRgb values\n\n @ingroup color"] + pub fn ghostty_color_palette_generate( + base: *const GhosttyColorRgb, + skip: *const GhosttyColorPaletteMask, + bg: *const GhosttyColorRgb, + fg: *const GhosttyColorRgb, + harmonious: bool, + out: *mut GhosttyColorRgb, + ); +} +unsafe extern "C" { + #[doc = " Calculate W3C relative luminance for an RGB color.\n\n Returns a normalized value from 0.0 for black to 1.0 for white.\n See https://www.w3.org/TR/WCAG20/#relativeluminancedef.\n\n @param color The RGB color (must not be NULL)\n @return Relative luminance in the range 0.0 to 1.0\n\n @ingroup color"] + pub fn ghostty_color_luminance(color: *const GhosttyColorRgb) -> f64; +} +unsafe extern "C" { + #[doc = " Calculate perceived luminance for an RGB color.\n\n Returns a normalized value from 0.0 for black to 1.0 for white.\n Ghostty treats a background color as light when this exceeds 0.5.\n This is not the metric used internally by\n ghostty_color_palette_generate(), which uses CIELAB lightness.\n\n @param color The RGB color (must not be NULL)\n @return Perceived luminance in the range 0.0 to 1.0\n\n @ingroup color"] + pub fn ghostty_color_perceived_luminance(color: *const GhosttyColorRgb) -> f64; +} +unsafe extern "C" { + #[doc = " Calculate the WCAG contrast ratio between two RGB colors.\n\n The contrast ratio is symmetric and ranges from 1.0 for identical\n colors to 21.0 for black and white.\n\n @param a The first RGB color (must not be NULL)\n @param b The second RGB color (must not be NULL)\n @return WCAG contrast ratio in the range 1.0 to 21.0\n\n @ingroup color"] + pub fn ghostty_color_contrast(a: *const GhosttyColorRgb, b: *const GhosttyColorRgb) -> f64; +} +unsafe extern "C" { + #[doc = " Get Ghostty's X11 color name table.\n\n The returned pointer references static memory valid for the program\n lifetime and is never NULL. Entries are in rgb.txt order and are\n terminated by an entry with name == NULL. Aliases are separate entries,\n such as \"medium spring green\" and \"MediumSpringGreen\". Names are the\n exact supported spellings from rgb.txt; ghostty_color_parse_x11() also\n matches them case-insensitively.\n\n @code{.c}\n for (const GhosttyColorX11Entry* e = ghostty_color_x11_names();\n e->name != NULL;\n e++) {\n // e->name and e->color are valid here.\n }\n @endcode\n\n @return Pointer to the first X11 color entry\n\n @ingroup color"] + pub fn ghostty_color_x11_names() -> *const GhosttyColorX11Entry; +} +unsafe extern "C" { + #[doc = " Get the number of X11 color name entries.\n\n The returned count excludes the NULL terminator and is provided so\n bindings can preallocate storage before reading ghostty_color_x11_names().\n\n @return Number of X11 color name entries\n\n @ingroup color"] + pub fn ghostty_color_x11_name_count() -> usize; } pub const GhosttyColorScheme_GHOSTTY_COLOR_SCHEME_LIGHT: GhosttyColorScheme = 0; pub const GhosttyColorScheme_GHOSTTY_COLOR_SCHEME_DARK: GhosttyColorScheme = 1; +pub const GhosttyColorScheme_GHOSTTY_COLOR_SCHEME_MAX_VALUE: GhosttyColorScheme = 2147483647; #[doc = " Color scheme reported in response to a CSI ? 996 n query.\n\n @ingroup terminal"] pub type GhosttyColorScheme = ::std::os::raw::c_uint; #[doc = " Primary device attributes (DA1) response data.\n\n Returned as part of GhosttyDeviceAttributes in response to a CSI c query.\n The conformance_level is the Pp parameter and features contains the Ps\n feature codes.\n\n @ingroup terminal"] @@ -395,10 +659,21 @@ impl Default for GhosttyDeviceAttributes { } } } +unsafe extern "C" { + #[doc = " Encode a color scheme report into an escape sequence.\n\n Encodes a color scheme report into the provided buffer. Dark color schemes\n emit ESC [ ? 997 ; 1 n, and light color schemes emit ESC [ ? 997 ; 2 n.\n The encoded bytes are identical to the terminal's internal CSI ? 996 n\n query response.\n\n Hosts should gate unsolicited sends on GHOSTTY_MODE_COLOR_SCHEME_REPORT\n (mode 2031) being set, which can be checked via the mode getters.\n\n If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE\n and writes the required buffer size to @p out_written. The caller can\n then retry with a sufficiently sized buffer.\n\n @param scheme The color scheme to encode\n @param buf Output buffer to write the encoded sequence into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] + pub fn ghostty_color_scheme_report_encode( + scheme: GhosttyColorScheme, + buf: *mut ::std::os::raw::c_char, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} #[doc = " Terminal window gained focus"] pub const GhosttyFocusEvent_GHOSTTY_FOCUS_GAINED: GhosttyFocusEvent = 0; #[doc = " Terminal window lost focus"] pub const GhosttyFocusEvent_GHOSTTY_FOCUS_LOST: GhosttyFocusEvent = 1; +#[doc = " Terminal window lost focus"] +pub const GhosttyFocusEvent_GHOSTTY_FOCUS_MAX_VALUE: GhosttyFocusEvent = 2147483647; #[doc = " Focus event types for focus reporting mode (mode 1004)."] pub type GhosttyFocusEvent = ::std::os::raw::c_uint; unsafe extern "C" { @@ -410,77 +685,6 @@ unsafe extern "C" { out_written: *mut usize, ) -> GhosttyResult; } -#[doc = " A packed 16-bit terminal mode.\n\n Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a\n single 16-bit integer. Use the inline helper functions to construct\n and inspect modes rather than manipulating bits directly."] -pub type GhosttyMode = u16; -#[doc = " Mode is not recognized"] -pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_NOT_RECOGNIZED: GhosttyModeReportState = 0; -#[doc = " Mode is set (enabled)"] -pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_SET: GhosttyModeReportState = 1; -#[doc = " Mode is reset (disabled)"] -pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_RESET: GhosttyModeReportState = 2; -#[doc = " Mode is permanently set"] -pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_PERMANENTLY_SET: GhosttyModeReportState = 3; -#[doc = " Mode is permanently reset"] -pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_PERMANENTLY_RESET: GhosttyModeReportState = 4; -#[doc = " DECRPM report state values.\n\n These correspond to the Ps2 parameter in a DECRPM response\n sequence (CSI ? Ps1 ; Ps2 $ y)."] -pub type GhosttyModeReportState = ::std::os::raw::c_uint; -unsafe extern "C" { - #[doc = " Encode a DECRPM (DEC Private Mode Report) response sequence.\n\n Writes a mode report escape sequence into the provided buffer.\n The generated sequence has the form:\n - DEC private mode: CSI ? Ps1 ; Ps2 $ y\n - ANSI mode: CSI Ps1 ; Ps2 $ y\n\n If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE\n and writes the required buffer size to @p out_written. The caller can\n then retry with a sufficiently sized buffer.\n\n @param mode The mode identifying the mode to report on\n @param state The report state for this mode\n @param buf Output buffer to write the encoded sequence into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] - pub fn ghostty_mode_report_encode( - mode: GhosttyMode, - state: GhosttyModeReportState, - buf: *mut ::std::os::raw::c_char, - buf_len: usize, - out_written: *mut usize, - ) -> GhosttyResult; -} -#[doc = " In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t"] -pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_MODE_2048: GhosttySizeReportStyle = 0; -#[doc = " XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t"] -pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_14_T: GhosttySizeReportStyle = 1; -#[doc = " XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t"] -pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_16_T: GhosttySizeReportStyle = 2; -#[doc = " XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t"] -pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_18_T: GhosttySizeReportStyle = 3; -#[doc = " Size report style.\n\n Determines the output format for the terminal size report."] -pub type GhosttySizeReportStyle = ::std::os::raw::c_uint; -#[doc = " Terminal size information for encoding size reports."] -#[repr(C)] -#[derive(Debug, Default, Copy, Clone)] -pub struct GhosttySizeReportSize { - #[doc = " Terminal row count in cells."] - pub rows: u16, - #[doc = " Terminal column count in cells."] - pub columns: u16, - #[doc = " Width of a single terminal cell in pixels."] - pub cell_width: u32, - #[doc = " Height of a single terminal cell in pixels."] - pub cell_height: u32, -} -#[allow(clippy::unnecessary_operation, clippy::identity_op)] -const _: () = { - ["Size of GhosttySizeReportSize"][::std::mem::size_of::<GhosttySizeReportSize>() - 12usize]; - ["Alignment of GhosttySizeReportSize"] - [::std::mem::align_of::<GhosttySizeReportSize>() - 4usize]; - ["Offset of field: GhosttySizeReportSize::rows"] - [::std::mem::offset_of!(GhosttySizeReportSize, rows) - 0usize]; - ["Offset of field: GhosttySizeReportSize::columns"] - [::std::mem::offset_of!(GhosttySizeReportSize, columns) - 2usize]; - ["Offset of field: GhosttySizeReportSize::cell_width"] - [::std::mem::offset_of!(GhosttySizeReportSize, cell_width) - 4usize]; - ["Offset of field: GhosttySizeReportSize::cell_height"] - [::std::mem::offset_of!(GhosttySizeReportSize, cell_height) - 8usize]; -}; -unsafe extern "C" { - #[doc = " Encode a terminal size report into an escape sequence.\n\n Encodes a size report in the format specified by @p style into the\n provided buffer.\n\n If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE\n and writes the required buffer size to @p out_written. The caller can\n then retry with a sufficiently sized buffer.\n\n @param style The size report format to encode\n @param size Terminal size information\n @param buf Output buffer to write the encoded sequence into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] - pub fn ghostty_size_report_encode( - style: GhosttySizeReportStyle, - size: GhosttySizeReportSize, - buf: *mut ::std::os::raw::c_char, - buf_len: usize, - out_written: *mut usize, - ) -> GhosttyResult; -} #[doc = " Opaque cell value.\n\n Represents a single terminal cell. The internal layout is opaque and\n must be queried via ghostty_cell_get(). Obtain cell values from\n terminal query APIs.\n\n @ingroup screen"] pub type GhosttyCell = u64; #[doc = " Opaque row value.\n\n Represents a single terminal row. The internal layout is opaque and\n must be queried via ghostty_row_get(). Obtain row values from\n terminal query APIs.\n\n @ingroup screen"] @@ -493,6 +697,9 @@ pub const GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME: Ghostty pub const GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE: GhosttyCellContentTag = 2; #[doc = " No text; background color as RGB."] pub const GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_BG_COLOR_RGB: GhosttyCellContentTag = 3; +#[doc = " No text; background color as RGB."] +pub const GhosttyCellContentTag_GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE: GhosttyCellContentTag = + 2147483647; #[doc = " Cell content tag.\n\n Describes what kind of content a cell holds.\n\n @ingroup screen"] pub type GhosttyCellContentTag = ::std::os::raw::c_uint; #[doc = " Not a wide character, cell width 1."] @@ -503,6 +710,8 @@ pub const GhosttyCellWide_GHOSTTY_CELL_WIDE_WIDE: GhosttyCellWide = 1; pub const GhosttyCellWide_GHOSTTY_CELL_WIDE_SPACER_TAIL: GhosttyCellWide = 2; #[doc = " Spacer at end of soft-wrapped line for a wide character."] pub const GhosttyCellWide_GHOSTTY_CELL_WIDE_SPACER_HEAD: GhosttyCellWide = 3; +#[doc = " Spacer at end of soft-wrapped line for a wide character."] +pub const GhosttyCellWide_GHOSTTY_CELL_WIDE_MAX_VALUE: GhosttyCellWide = 2147483647; #[doc = " Cell wide property.\n\n Describes the width behavior of a cell.\n\n @ingroup screen"] pub type GhosttyCellWide = ::std::os::raw::c_uint; #[doc = " Regular output content, such as command output."] @@ -511,6 +720,9 @@ pub const GhosttyCellSemanticContent_GHOSTTY_CELL_SEMANTIC_OUTPUT: GhosttyCellSe pub const GhosttyCellSemanticContent_GHOSTTY_CELL_SEMANTIC_INPUT: GhosttyCellSemanticContent = 1; #[doc = " Content that is part of a shell prompt."] pub const GhosttyCellSemanticContent_GHOSTTY_CELL_SEMANTIC_PROMPT: GhosttyCellSemanticContent = 2; +#[doc = " Content that is part of a shell prompt."] +pub const GhosttyCellSemanticContent_GHOSTTY_CELL_SEMANTIC_MAX_VALUE: GhosttyCellSemanticContent = + 2147483647; #[doc = " Semantic content type of a cell.\n\n Set by semantic prompt sequences (OSC 133) to distinguish between\n command output, user input, and shell prompt text.\n\n @ingroup screen"] pub type GhosttyCellSemanticContent = ::std::os::raw::c_uint; #[doc = " Invalid data type. Never results in any data extraction."] @@ -537,6 +749,8 @@ pub const GhosttyCellData_GHOSTTY_CELL_DATA_SEMANTIC_CONTENT: GhosttyCellData = pub const GhosttyCellData_GHOSTTY_CELL_DATA_COLOR_PALETTE: GhosttyCellData = 10; #[doc = " The RGB value for the cell's background color.\n Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB.\n\n Output type: GhosttyColorRgb *"] pub const GhosttyCellData_GHOSTTY_CELL_DATA_COLOR_RGB: GhosttyCellData = 11; +#[doc = " The RGB value for the cell's background color.\n Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyCellData_GHOSTTY_CELL_DATA_MAX_VALUE: GhosttyCellData = 2147483647; #[doc = " Cell data types.\n\n These values specify what type of data to extract from a cell\n using `ghostty_cell_get`.\n\n @ingroup screen"] pub type GhosttyCellData = ::std::os::raw::c_uint; #[doc = " No prompt cells in this row."] @@ -546,6 +760,9 @@ pub const GhosttyRowSemanticPrompt_GHOSTTY_ROW_SEMANTIC_PROMPT: GhosttyRowSemant #[doc = " Prompt cells exist and this is a continuation line."] pub const GhosttyRowSemanticPrompt_GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION: GhosttyRowSemanticPrompt = 2; +#[doc = " Prompt cells exist and this is a continuation line."] +pub const GhosttyRowSemanticPrompt_GHOSTTY_ROW_SEMANTIC_MAX_VALUE: GhosttyRowSemanticPrompt = + 2147483647; #[doc = " Row semantic prompt state.\n\n Indicates whether any cells in a row are part of a shell prompt,\n as reported by OSC 133 sequences.\n\n @ingroup screen"] pub type GhosttyRowSemanticPrompt = ::std::os::raw::c_uint; #[doc = " Invalid data type. Never results in any data extraction."] @@ -566,6 +783,8 @@ pub const GhosttyRowData_GHOSTTY_ROW_DATA_SEMANTIC_PROMPT: GhosttyRowData = 6; pub const GhosttyRowData_GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER: GhosttyRowData = 7; #[doc = " Whether this row is dirty and requires a redraw.\n\n Output type: bool *"] pub const GhosttyRowData_GHOSTTY_ROW_DATA_DIRTY: GhosttyRowData = 8; +#[doc = " Whether this row is dirty and requires a redraw.\n\n Output type: bool *"] +pub const GhosttyRowData_GHOSTTY_ROW_DATA_MAX_VALUE: GhosttyRowData = 2147483647; #[doc = " Row data types.\n\n These values specify what type of data to extract from a row\n using `ghostty_row_get`.\n\n @ingroup screen"] pub type GhosttyRowData = ::std::os::raw::c_uint; unsafe extern "C" { @@ -577,7 +796,7 @@ unsafe extern "C" { ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get multiple data fields from a cell in a single call."] + #[doc = " Get multiple data fields from a cell in a single call.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param cell The cell value\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup screen"] pub fn ghostty_cell_get_multi( cell: GhosttyCell, count: usize, @@ -595,7 +814,7 @@ unsafe extern "C" { ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get multiple data fields from a row in a single call."] + #[doc = " Get multiple data fields from a row in a single call.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param row The row value\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup screen"] pub fn ghostty_row_get_multi( row: GhosttyRow, count: usize, @@ -609,6 +828,7 @@ pub type GhosttyStyleId = u16; pub const GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_NONE: GhosttyStyleColorTag = 0; pub const GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_PALETTE: GhosttyStyleColorTag = 1; pub const GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_RGB: GhosttyStyleColorTag = 2; +pub const GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE: GhosttyStyleColorTag = 2147483647; #[doc = " Style color tags.\n\n These values identify the type of color in a style color.\n Use the tag to determine which field in the color value union to access.\n\n @ingroup style"] pub type GhosttyStyleColorTag = ::std::os::raw::c_uint; #[doc = " Style color value union.\n\n Use the tag to determine which field is active.\n\n @ingroup style"] @@ -757,28 +977,6 @@ impl Default for GhosttyGridRef { } } } -#[doc = " A selection range defined by two grid references."] -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttySelection { - pub size: usize, - pub start: GhosttyGridRef, - pub end: GhosttyGridRef, - pub rectangle: bool, -} -#[allow(clippy::unnecessary_operation, clippy::identity_op)] -const _: () = { - ["Size of GhosttySelection"][::std::mem::size_of::<GhosttySelection>() - 64usize]; - ["Alignment of GhosttySelection"][::std::mem::align_of::<GhosttySelection>() - 8usize]; - ["Offset of field: GhosttySelection::size"] - [::std::mem::offset_of!(GhosttySelection, size) - 0usize]; - ["Offset of field: GhosttySelection::start"] - [::std::mem::offset_of!(GhosttySelection, start) - 8usize]; - ["Offset of field: GhosttySelection::end"] - [::std::mem::offset_of!(GhosttySelection, end) - 32usize]; - ["Offset of field: GhosttySelection::rectangle"] - [::std::mem::offset_of!(GhosttySelection, rectangle) - 56usize]; -}; unsafe extern "C" { #[doc = " Get the cell from a grid reference.\n\n @param ref Pointer to the grid reference\n @param[out] out_cell On success, set to the cell at the ref's position (may be NULL)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's\n node is NULL\n\n @ingroup grid_ref"] pub fn ghostty_grid_ref_cell( @@ -803,7 +1001,7 @@ unsafe extern "C" { ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get the hyperlink URI for the cell at the grid reference's position."] + #[doc = " Get the hyperlink URI for the cell at the grid reference's position.\n\n Writes the URI bytes into the provided buffer. If the cell has no\n hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned.\n\n If the buffer is too small (or NULL), the function returns\n GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to\n out_len. The caller can then retry with a sufficiently sized buffer.\n\n @param ref Pointer to the grid reference\n @param buf Output buffer for the URI bytes (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_len On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's\n node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small\n\n @ingroup grid_ref"] pub fn ghostty_grid_ref_hyperlink_uri( ref_: *const GhosttyGridRef, buf: *mut u8, @@ -845,6 +1043,8 @@ pub const GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT: GhosttyPointTag = 1; pub const GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN: GhosttyPointTag = 2; #[doc = " Scrollback history only (before active area)."] pub const GhosttyPointTag_GHOSTTY_POINT_TAG_HISTORY: GhosttyPointTag = 3; +#[doc = " Scrollback history only (before active area)."] +pub const GhosttyPointTag_GHOSTTY_POINT_TAG_MAX_VALUE: GhosttyPointTag = 2147483647; #[doc = " Point reference tag.\n\n Determines which coordinate system a point uses.\n\n @ingroup point"] pub type GhosttyPointTag = ::std::os::raw::c_uint; #[doc = " Point value union.\n\n @ingroup point"] @@ -899,11 +1099,1002 @@ impl Default for GhosttyPoint { } #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct GhosttyTerminal { +pub struct GhosttySelectionGestureImpl { _unused: [u8; 0], } -#[doc = " Opaque handle to a terminal instance.\n\n @ingroup terminal"] -pub type GhosttyTerminal_ptr = *mut GhosttyTerminal; +#[doc = " Opaque handle to state for interpreting terminal selection gestures.\n\n The gesture owns only the state required to interpret pointer events. Calls\n that use a gesture are not concurrency-safe and must be serialized with\n terminal mutations.\n\n @ingroup selection"] +pub type GhosttySelectionGesture = *mut GhosttySelectionGestureImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttySelectionGestureEventImpl { + _unused: [u8; 0], +} +#[doc = " Opaque handle to reusable input data for selection gesture operations.\n\n Event options are set with ghostty_selection_gesture_event_set(). Individual\n gesture operations document which options are required or optional.\n\n @ingroup selection"] +pub type GhosttySelectionGestureEvent = *mut GhosttySelectionGestureEventImpl; +#[doc = " A snapshot selection range defined by two grid references.\n\n Both endpoints are inclusive. The endpoints preserve selection direction\n and may be reversed; callers must not assume that start is the top-left\n endpoint or that end is the bottom-right endpoint.\n\n When rectangle is false, the endpoints describe a linear selection. When\n rectangle is true, the same endpoints are interpreted as opposite corners\n of a rectangular/block selection.\n\n The start and end values are untracked GhosttyGridRef snapshots and are\n only valid until the next mutating operation on the terminal that produced\n them unless the selection is reconstructed from tracked references.\n\n This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttySelection { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttySelection)."] + pub size: usize, + #[doc = " Start of the selection range (inclusive).\n\n This may be after end in terminal order. It is an untracked\n GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules."] + pub start: GhosttyGridRef, + #[doc = " End of the selection range (inclusive).\n\n This may be before start in terminal order. It is an untracked\n GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules."] + pub end: GhosttyGridRef, + #[doc = " Whether the endpoints are interpreted as a rectangular/block selection\n rather than a linear selection."] + pub rectangle: bool, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySelection"][::std::mem::size_of::<GhosttySelection>() - 64usize]; + ["Alignment of GhosttySelection"][::std::mem::align_of::<GhosttySelection>() - 8usize]; + ["Offset of field: GhosttySelection::size"] + [::std::mem::offset_of!(GhosttySelection, size) - 0usize]; + ["Offset of field: GhosttySelection::start"] + [::std::mem::offset_of!(GhosttySelection, start) - 8usize]; + ["Offset of field: GhosttySelection::end"] + [::std::mem::offset_of!(GhosttySelection, end) - 32usize]; + ["Offset of field: GhosttySelection::rectangle"] + [::std::mem::offset_of!(GhosttySelection, rectangle) - 56usize]; +}; +impl Default for GhosttySelection { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Options for deriving a word selection from a terminal grid reference.\n\n This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.\n If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's\n default word-boundary codepoints are used. If boundary_codepoints_len is\n non-zero, boundary_codepoints must not be NULL.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalSelectWordOptions { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions)."] + pub size: usize, + #[doc = " Grid reference under which to derive the word selection."] + pub ref_: GhosttyGridRef, + #[doc = " Optional word-boundary codepoints as uint32_t scalar values."] + pub boundary_codepoints: *const u32, + #[doc = " Number of entries in boundary_codepoints."] + pub boundary_codepoints_len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalSelectWordOptions"] + [::std::mem::size_of::<GhosttyTerminalSelectWordOptions>() - 48usize]; + ["Alignment of GhosttyTerminalSelectWordOptions"] + [::std::mem::align_of::<GhosttyTerminalSelectWordOptions>() - 8usize]; + ["Offset of field: GhosttyTerminalSelectWordOptions::size"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordOptions, size) - 0usize]; + ["Offset of field: GhosttyTerminalSelectWordOptions::ref_"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordOptions, ref_) - 8usize]; + ["Offset of field: GhosttyTerminalSelectWordOptions::boundary_codepoints"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordOptions, boundary_codepoints) - 32usize]; + ["Offset of field: GhosttyTerminalSelectWordOptions::boundary_codepoints_len"][::std::mem::offset_of!( + GhosttyTerminalSelectWordOptions, + boundary_codepoints_len + ) - 40usize]; +}; +impl Default for GhosttyTerminalSelectWordOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Options for deriving the nearest word selection between two grid references.\n\n This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.\n If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's\n default word-boundary codepoints are used. If boundary_codepoints_len is\n non-zero, boundary_codepoints must not be NULL.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalSelectWordBetweenOptions { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions)."] + pub size: usize, + #[doc = " Starting grid reference for the inclusive search range."] + pub start: GhosttyGridRef, + #[doc = " Ending grid reference for the inclusive search range."] + pub end: GhosttyGridRef, + #[doc = " Optional word-boundary codepoints as uint32_t scalar values."] + pub boundary_codepoints: *const u32, + #[doc = " Number of entries in boundary_codepoints."] + pub boundary_codepoints_len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalSelectWordBetweenOptions"] + [::std::mem::size_of::<GhosttyTerminalSelectWordBetweenOptions>() - 72usize]; + ["Alignment of GhosttyTerminalSelectWordBetweenOptions"] + [::std::mem::align_of::<GhosttyTerminalSelectWordBetweenOptions>() - 8usize]; + ["Offset of field: GhosttyTerminalSelectWordBetweenOptions::size"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordBetweenOptions, size) - 0usize]; + ["Offset of field: GhosttyTerminalSelectWordBetweenOptions::start"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordBetweenOptions, start) - 8usize]; + ["Offset of field: GhosttyTerminalSelectWordBetweenOptions::end"] + [::std::mem::offset_of!(GhosttyTerminalSelectWordBetweenOptions, end) - 32usize]; + ["Offset of field: GhosttyTerminalSelectWordBetweenOptions::boundary_codepoints"][::std::mem::offset_of!( + GhosttyTerminalSelectWordBetweenOptions, + boundary_codepoints + ) - 56usize]; + ["Offset of field: GhosttyTerminalSelectWordBetweenOptions::boundary_codepoints_len"][::std::mem::offset_of!( + GhosttyTerminalSelectWordBetweenOptions, + boundary_codepoints_len + ) + - 64usize]; +}; +impl Default for GhosttyTerminalSelectWordBetweenOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Options for deriving a line selection from a terminal grid reference.\n\n This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.\n If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim\n whitespace codepoints are used. If whitespace_len is non-zero, whitespace\n must not be NULL.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalSelectLineOptions { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions)."] + pub size: usize, + #[doc = " Grid reference under which to derive the line selection."] + pub ref_: GhosttyGridRef, + #[doc = " Optional codepoints to trim from the start and end of the line."] + pub whitespace: *const u32, + #[doc = " Number of entries in whitespace."] + pub whitespace_len: usize, + #[doc = " Whether semantic prompt state changes should bound the line selection."] + pub semantic_prompt_boundary: bool, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalSelectLineOptions"] + [::std::mem::size_of::<GhosttyTerminalSelectLineOptions>() - 56usize]; + ["Alignment of GhosttyTerminalSelectLineOptions"] + [::std::mem::align_of::<GhosttyTerminalSelectLineOptions>() - 8usize]; + ["Offset of field: GhosttyTerminalSelectLineOptions::size"] + [::std::mem::offset_of!(GhosttyTerminalSelectLineOptions, size) - 0usize]; + ["Offset of field: GhosttyTerminalSelectLineOptions::ref_"] + [::std::mem::offset_of!(GhosttyTerminalSelectLineOptions, ref_) - 8usize]; + ["Offset of field: GhosttyTerminalSelectLineOptions::whitespace"] + [::std::mem::offset_of!(GhosttyTerminalSelectLineOptions, whitespace) - 32usize]; + ["Offset of field: GhosttyTerminalSelectLineOptions::whitespace_len"] + [::std::mem::offset_of!(GhosttyTerminalSelectLineOptions, whitespace_len) - 40usize]; + ["Offset of field: GhosttyTerminalSelectLineOptions::semantic_prompt_boundary"][::std::mem::offset_of!( + GhosttyTerminalSelectLineOptions, + semantic_prompt_boundary + ) - 48usize]; +}; +impl Default for GhosttyTerminalSelectLineOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Options for one-shot formatting of a terminal selection.\n\n This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.\n\n If selection is NULL, the terminal's current active selection is used.\n If selection is non-NULL, that caller-provided snapshot selection is used.\n\n The selection is formatted from the terminal's active screen using the same\n formatting semantics as GhosttyFormatter. For copy/clipboard behavior\n matching Ghostty's Screen.selectionString(), use plain output with unwrap\n and trim both set to true.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalSelectionFormatOptions { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions)."] + pub size: usize, + #[doc = " Output format to emit."] + pub emit: GhosttyFormatterFormat, + #[doc = " Whether to unwrap soft-wrapped lines."] + pub unwrap: bool, + #[doc = " Whether to trim trailing whitespace on non-blank lines."] + pub trim: bool, + #[doc = " Optional selection to format.\n\n If NULL, the terminal's current active selection is used. If the terminal\n has no active selection, formatting returns GHOSTTY_NO_VALUE.\n\n If non-NULL, the pointed-to selection must be a valid snapshot selection\n for this terminal and must obey GhosttySelection lifetime rules."] + pub selection: *const GhosttySelection, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalSelectionFormatOptions"] + [::std::mem::size_of::<GhosttyTerminalSelectionFormatOptions>() - 24usize]; + ["Alignment of GhosttyTerminalSelectionFormatOptions"] + [::std::mem::align_of::<GhosttyTerminalSelectionFormatOptions>() - 8usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::size"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, size) - 0usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::emit"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, emit) - 8usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::unwrap"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, unwrap) - 12usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::trim"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, trim) - 13usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::selection"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, selection) - 16usize]; +}; +impl Default for GhosttyTerminalSelectionFormatOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Start is before end in top-left to bottom-right order."] +pub const GhosttySelectionOrder_GHOSTTY_SELECTION_ORDER_FORWARD: GhosttySelectionOrder = 0; +#[doc = " End is before start in top-left to bottom-right order."] +pub const GhosttySelectionOrder_GHOSTTY_SELECTION_ORDER_REVERSE: GhosttySelectionOrder = 1; +#[doc = " Rectangular selection from top-right to bottom-left."] +pub const GhosttySelectionOrder_GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD: GhosttySelectionOrder = 2; +#[doc = " Rectangular selection from bottom-left to top-right."] +pub const GhosttySelectionOrder_GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE: GhosttySelectionOrder = 3; +#[doc = " Rectangular selection from bottom-left to top-right."] +pub const GhosttySelectionOrder_GHOSTTY_SELECTION_ORDER_MAX_VALUE: GhosttySelectionOrder = + 2147483647; +#[doc = " Ordering of a selection's endpoints in terminal coordinates.\n\n Mirrored orders are only produced by rectangular selections whose start\n and end endpoints are on opposite diagonal corners that are not simple\n top-left-to-bottom-right or bottom-right-to-top-left orderings.\n\n @ingroup selection"] +pub type GhosttySelectionOrder = ::std::os::raw::c_uint; +#[doc = " Move left to the previous non-empty cell, wrapping upward."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_LEFT: GhosttySelectionAdjust = 0; +#[doc = " Move right to the next non-empty cell, wrapping downward."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_RIGHT: GhosttySelectionAdjust = 1; +#[doc = " Move up one row at the current column, or to the beginning of the\n line if already at the top."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_UP: GhosttySelectionAdjust = 2; +#[doc = " Move down to the next non-blank row at the current column, or to the\n end of the line if none exists."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_DOWN: GhosttySelectionAdjust = 3; +#[doc = " Move to the top-left cell of the screen."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_HOME: GhosttySelectionAdjust = 4; +#[doc = " Move to the right edge of the last non-blank row on the screen."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_END: GhosttySelectionAdjust = 5; +#[doc = " Move up by one terminal page height, or to home if that would move\n past the top."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_PAGE_UP: GhosttySelectionAdjust = 6; +#[doc = " Move down by one terminal page height, or to end if that would move\n past the bottom."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_PAGE_DOWN: GhosttySelectionAdjust = 7; +#[doc = " Move to the left edge of the current line."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE: + GhosttySelectionAdjust = 8; +#[doc = " Move to the right edge of the current line."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_END_OF_LINE: GhosttySelectionAdjust = 9; +#[doc = " Move to the right edge of the current line."] +pub const GhosttySelectionAdjust_GHOSTTY_SELECTION_ADJUST_MAX_VALUE: GhosttySelectionAdjust = + 2147483647; +#[doc = " Operation used to adjust a selection endpoint.\n\n Adjustment mutates the selection's logical end endpoint, not whichever\n endpoint is visually bottom/right. This preserves keyboard and drag\n behavior for both forward and reversed selections.\n\n @ingroup selection"] +pub type GhosttySelectionAdjust = ::std::os::raw::c_uint; +#[doc = " Cell-granular drag selection."] +pub const GhosttySelectionGestureBehavior_GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL: + GhosttySelectionGestureBehavior = 0; +#[doc = " Word selection on press and word-granular drag selection."] +pub const GhosttySelectionGestureBehavior_GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD: + GhosttySelectionGestureBehavior = 1; +#[doc = " Line selection on press and line-granular drag selection."] +pub const GhosttySelectionGestureBehavior_GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE: + GhosttySelectionGestureBehavior = 2; +#[doc = " Semantic command output selection on press and drag."] +pub const GhosttySelectionGestureBehavior_GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT: + GhosttySelectionGestureBehavior = 3; +#[doc = " Semantic command output selection on press and drag."] +pub const GhosttySelectionGestureBehavior_GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE: + GhosttySelectionGestureBehavior = 2147483647; +#[doc = " Selection behavior chosen for a gesture's click sequence.\n\n @ingroup selection"] +pub type GhosttySelectionGestureBehavior = ::std::os::raw::c_uint; +#[doc = " Selection behaviors for single-, double-, and triple-click gestures.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttySelectionGestureBehaviors { + #[doc = " Behavior for single-click selection gestures."] + pub single_click: GhosttySelectionGestureBehavior, + #[doc = " Behavior for double-click selection gestures."] + pub double_click: GhosttySelectionGestureBehavior, + #[doc = " Behavior for triple-click selection gestures."] + pub triple_click: GhosttySelectionGestureBehavior, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySelectionGestureBehaviors"] + [::std::mem::size_of::<GhosttySelectionGestureBehaviors>() - 12usize]; + ["Alignment of GhosttySelectionGestureBehaviors"] + [::std::mem::align_of::<GhosttySelectionGestureBehaviors>() - 4usize]; + ["Offset of field: GhosttySelectionGestureBehaviors::single_click"] + [::std::mem::offset_of!(GhosttySelectionGestureBehaviors, single_click) - 0usize]; + ["Offset of field: GhosttySelectionGestureBehaviors::double_click"] + [::std::mem::offset_of!(GhosttySelectionGestureBehaviors, double_click) - 4usize]; + ["Offset of field: GhosttySelectionGestureBehaviors::triple_click"] + [::std::mem::offset_of!(GhosttySelectionGestureBehaviors, triple_click) - 8usize]; +}; +impl Default for GhosttySelectionGestureBehaviors { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Display geometry used to interpret selection gesture drag events.\n\n @ingroup selection"] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttySelectionGestureGeometry { + #[doc = " Number of columns in the rendered terminal grid. Must be non-zero."] + pub columns: u32, + #[doc = " Width of one terminal cell in surface pixels. Must be non-zero."] + pub cell_width: u32, + #[doc = " Left padding before the terminal grid begins in surface pixels."] + pub padding_left: u32, + #[doc = " Height of the rendered terminal surface in surface pixels. Must be non-zero."] + pub screen_height: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySelectionGestureGeometry"] + [::std::mem::size_of::<GhosttySelectionGestureGeometry>() - 16usize]; + ["Alignment of GhosttySelectionGestureGeometry"] + [::std::mem::align_of::<GhosttySelectionGestureGeometry>() - 4usize]; + ["Offset of field: GhosttySelectionGestureGeometry::columns"] + [::std::mem::offset_of!(GhosttySelectionGestureGeometry, columns) - 0usize]; + ["Offset of field: GhosttySelectionGestureGeometry::cell_width"] + [::std::mem::offset_of!(GhosttySelectionGestureGeometry, cell_width) - 4usize]; + ["Offset of field: GhosttySelectionGestureGeometry::padding_left"] + [::std::mem::offset_of!(GhosttySelectionGestureGeometry, padding_left) - 8usize]; + ["Offset of field: GhosttySelectionGestureGeometry::screen_height"] + [::std::mem::offset_of!(GhosttySelectionGestureGeometry, screen_height) - 12usize]; +}; +#[doc = " No selection autoscroll is requested."] +pub const GhosttySelectionGestureAutoscroll_GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE: + GhosttySelectionGestureAutoscroll = 0; +#[doc = " Selection dragging should autoscroll the viewport upward."] +pub const GhosttySelectionGestureAutoscroll_GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP: + GhosttySelectionGestureAutoscroll = 1; +#[doc = " Selection dragging should autoscroll the viewport downward."] +pub const GhosttySelectionGestureAutoscroll_GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN: + GhosttySelectionGestureAutoscroll = 2; +#[doc = " Selection dragging should autoscroll the viewport downward."] +pub const GhosttySelectionGestureAutoscroll_GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE: + GhosttySelectionGestureAutoscroll = 2147483647; +#[doc = " Current autoscroll direction for an active selection drag gesture.\n\n @ingroup selection"] +pub type GhosttySelectionGestureAutoscroll = ::std::os::raw::c_uint; +#[doc = " Current click count: uint8_t*. 0 means inactive."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT: + GhosttySelectionGestureData = 0; +#[doc = " Whether the current/last left-click gesture has dragged: bool*."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED: + GhosttySelectionGestureData = 1; +#[doc = " Current autoscroll request: GhosttySelectionGestureAutoscroll*."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL: + GhosttySelectionGestureData = 2; +#[doc = " Current gesture behavior: GhosttySelectionGestureBehavior*."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR: + GhosttySelectionGestureData = 3; +#[doc = " Current left-click anchor: GhosttyGridRef*.\n\n Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success,\n writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef\n lifetime rules."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR: + GhosttySelectionGestureData = 4; +#[doc = " Current left-click anchor: GhosttyGridRef*.\n\n Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success,\n writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef\n lifetime rules."] +pub const GhosttySelectionGestureData_GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE: + GhosttySelectionGestureData = 2147483647; +#[doc = " Data fields readable from a selection gesture with\n ghostty_selection_gesture_get().\n\n @ingroup selection"] +pub type GhosttySelectionGestureData = ::std::os::raw::c_uint; +#[doc = " Press event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS: + GhosttySelectionGestureEventType = 0; +#[doc = " Release event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE: + GhosttySelectionGestureEventType = 1; +#[doc = " Drag event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG: + GhosttySelectionGestureEventType = 2; +#[doc = " Autoscroll tick event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK: + GhosttySelectionGestureEventType = 3; +#[doc = " Deep press event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS: + GhosttySelectionGestureEventType = 4; +#[doc = " Deep press event for ghostty_selection_gesture_event()."] +pub const GhosttySelectionGestureEventType_GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE: + GhosttySelectionGestureEventType = 2147483647; +#[doc = " Selection gesture event type.\n\n The event type is fixed when the event is created. Each event type documents\n which options are valid and which options are required by gesture operations.\n\n @ingroup selection"] +pub type GhosttySelectionGestureEventType = ::std::os::raw::c_uint; +#[doc = " Grid reference under the pointer: GhosttyGridRef*.\n\n Required for PRESS and DRAG events. Optional for RELEASE events; when unset\n or cleared, release records that the pointer did not map to a valid cell."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF: + GhosttySelectionGestureEventOption = 0; +#[doc = " Surface-space pointer position: GhosttySurfacePosition*.\n\n Valid for PRESS, DRAG, and AUTOSCROLL_TICK."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION: + GhosttySelectionGestureEventOption = 1; +#[doc = " Maximum repeat-click distance in pixels: double*."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE: + GhosttySelectionGestureEventOption = 2; +#[doc = " Optional monotonic event time in nanoseconds: uint64_t*.\n\n If unset, press treats the event as untimed and only single-click behavior\n is available."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS: + GhosttySelectionGestureEventOption = 3; +#[doc = " Maximum interval between repeat clicks in nanoseconds: uint64_t*."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS : GhosttySelectionGestureEventOption = 4 ; +#[doc = " Word-boundary codepoints: GhosttyCodepoints*.\n\n The codepoints are copied into event-owned storage when set. If unset,\n operations that need word boundaries use Ghostty's defaults.\n\n Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS : GhosttySelectionGestureEventOption = 5 ; +#[doc = " Selection behavior table: GhosttySelectionGestureBehaviors*.\n\n If unset, press uses the default behavior table: cell, word, line."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS: + GhosttySelectionGestureEventOption = 6; +#[doc = " Whether a drag or autoscroll tick should produce a rectangular selection: bool*."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE: + GhosttySelectionGestureEventOption = 7; +#[doc = " Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY: + GhosttySelectionGestureEventOption = 8; +#[doc = " Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT: + GhosttySelectionGestureEventOption = 9; +#[doc = " Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK."] +pub const GhosttySelectionGestureEventOption_GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE: + GhosttySelectionGestureEventOption = 2147483647; +#[doc = " Options stored on a reusable selection gesture event.\n\n Passing NULL as the value to ghostty_selection_gesture_event_set() clears the\n corresponding option.\n\n @ingroup selection"] +pub type GhosttySelectionGestureEventOption = ::std::os::raw::c_uint; +unsafe extern "C" { + #[doc = " Create a reusable selection gesture event object.\n\n @param allocator Allocator, or NULL for the default allocator\n @param out_event Receives the created event handle\n @param type Event type. This is fixed for the lifetime of the event.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is\n NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_event_new( + allocator: *const GhosttyAllocator, + out_event: *mut GhosttySelectionGestureEvent, + type_: GhosttySelectionGestureEventType, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Free a selection gesture event object.\n\n Passing NULL is allowed and is a no-op.\n\n @param event Selection gesture event handle to free\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_event_free(event: GhosttySelectionGestureEvent); +} +unsafe extern "C" { + #[doc = " Set or clear an option on a selection gesture event.\n\n The value type depends on option and is documented by\n GhosttySelectionGestureEventOption. Passing NULL for value clears the option.\n\n @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param option Event option to set or clear\n @param value Pointer to the input value for option, or NULL to clear\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying\n event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or\n value is invalid\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_event_set( + event: GhosttySelectionGestureEvent, + option: GhosttySelectionGestureEventOption, + value: *const ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Apply a selection gesture event and return the resulting selection snapshot.\n\n This dispatches to the gesture operation matching the event's fixed type.\n For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function.\n All other press options use their initialized defaults when unset or cleared.\n\n For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or\n cleared, release records that the pointer did not map to a valid cell. Release\n events update gesture state but do not produce a selection, so this function\n returns GHOSTTY_NO_VALUE after applying them.\n\n For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG,\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position,\n rectangle, and word-boundary codepoints are optional and use initialized\n defaults when unset or cleared.\n\n For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK,\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position,\n rectangle, and word-boundary codepoints are optional and use initialized\n defaults when unset or cleared.\n\n For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only\n GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is\n optional and uses initialized defaults when unset or cleared.\n\n The returned selection is not installed as the terminal's current selection.\n It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal Terminal used to interpret and update gesture state\n @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param[out] out_selection On success, receives the resulting selection. May\n be NULL to apply the event and discard the selection result.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not\n currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking\n gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal,\n event, or required event data is invalid\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_event( + gesture: GhosttySelectionGesture, + terminal: GhosttyTerminal, + event: GhosttySelectionGestureEvent, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Create a selection gesture object.\n\n The gesture stores mutable state for terminal text selection gestures. The\n gesture is not bound to a terminal at creation time; terminal-dependent APIs\n take the terminal explicitly.\n\n @param allocator Allocator, or NULL for the default allocator\n @param out_gesture Receives the created gesture handle\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is\n NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_new( + allocator: *const GhosttyAllocator, + out_gesture: *mut GhosttySelectionGesture, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Free a selection gesture object.\n\n This releases any tracked terminal references owned by the gesture using the\n provided terminal, then frees the gesture object. Passing NULL for gesture is\n allowed and is a no-op.\n\n If the terminal is still alive, pass the terminal most recently used with the\n gesture so any tracked terminal references can be released correctly. If the\n terminal has already been freed, pass NULL for terminal; the terminal's page\n storage has already released the underlying tracked references, so the\n gesture wrapper can be safely discarded without touching the stale terminal\n state.\n\n @param gesture Selection gesture handle to free\n @param terminal Terminal used to release tracked gesture state, or NULL if\n the terminal has already been freed\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_free( + gesture: GhosttySelectionGesture, + terminal: GhosttyTerminal, + ); +} +unsafe extern "C" { + #[doc = " Reset any active selection gesture state.\n\n This cancels the active click sequence and releases any tracked terminal\n references owned by the gesture without freeing the gesture object.\n Passing NULL is allowed and is a no-op.\n\n @param gesture Selection gesture handle to reset\n @param terminal Terminal used to release tracked gesture state\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_reset( + gesture: GhosttySelectionGesture, + terminal: GhosttyTerminal, + ); +} +unsafe extern "C" { + #[doc = " Read data from a selection gesture.\n\n The type of value depends on data and is documented by\n GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR,\n the returned GhosttyGridRef is an untracked snapshot with normal grid-ref\n lifetime rules.\n\n @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal Terminal used to validate terminal-backed gesture state\n @param data Data field to read\n @param value Output pointer whose type depends on data\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data\n has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or\n value is invalid\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_get( + gesture: GhosttySelectionGesture, + terminal: GhosttyTerminal, + data: GhosttySelectionGestureData, + value: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Read multiple data fields from a selection gesture in a single call.\n\n This is an optimization over calling ghostty_selection_gesture_get() multiple\n times. Each entry in values must point to storage of the type documented by\n the corresponding GhosttySelectionGestureData key.\n\n If any individual read fails, the function returns that error and writes the\n index of the failing key to out_written when out_written is non-NULL. On\n success, out_written receives count when non-NULL.\n\n @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal Terminal used to validate terminal-backed gesture state\n @param count Number of data fields to read\n @param keys Data fields to read (must not be NULL)\n @param values Output pointers corresponding to keys (must not be NULL)\n @param out_written Optional number of fields read, or failing index on error\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data\n field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal,\n keys, values, or a value pointer is invalid\n\n @ingroup selection"] + pub fn ghostty_selection_gesture_get_multi( + gesture: GhosttySelectionGesture, + terminal: GhosttyTerminal, + count: usize, + keys: *const GhosttySelectionGestureData, + values: *mut *mut ::std::os::raw::c_void, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Derive a word selection snapshot from a terminal grid reference.\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Word-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has\n no selectable word content, or GHOSTTY_INVALID_VALUE if the\n terminal, options, ref, codepoint pointer, or output pointer are\n invalid.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_word( + terminal: GhosttyTerminal, + options: *const GhosttyTerminalSelectWordOptions, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Derive the nearest word selection snapshot between two terminal grid refs.\n\n Starting at options->start, this searches toward options->end (inclusive)\n and returns the first selectable word found using Ghostty's word-selection\n rules.\n\n This is useful for implementing double-click-and-drag selection in a UI. If\n a user double-clicks one word and drags across spaces or punctuation toward\n another word, selecting only the word directly under the current pointer can\n flicker or collapse when the pointer is between words. Instead, ask for the\n nearest word between the original click and the drag point, ask again in the\n reverse direction, and combine the two word bounds into the drag selection.\n\n @snippet c-vt-selection/src/main.c selection-word-between\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Word-between-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no\n selectable word content between the valid refs, or\n GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint\n pointer, or output pointer are invalid.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_word_between( + terminal: GhosttyTerminal, + options: *const GhosttyTerminalSelectWordBetweenOptions, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Derive a line selection snapshot from a terminal grid reference.\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Line-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has\n no selectable line content, or GHOSTTY_INVALID_VALUE if the\n terminal, options, ref, codepoint pointer, or output pointer are\n invalid.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_line( + terminal: GhosttyTerminal, + options: *const GhosttyTerminalSelectLineOptions, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Derive a selection snapshot covering all selectable terminal content.\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no\n selectable content, or GHOSTTY_INVALID_VALUE if the terminal or\n output pointer is invalid.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_all( + terminal: GhosttyTerminal, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Derive a command-output selection snapshot from a terminal grid reference.\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param ref Grid reference within command output to select\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is\n not selectable command output, or GHOSTTY_INVALID_VALUE if the\n terminal, ref, or output pointer is invalid.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_output( + terminal: GhosttyTerminal, + ref_: GhosttyGridRef, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Format a terminal selection into a caller-provided buffer.\n\n This is a one-shot convenience API for formatting either the terminal's\n active selection or a caller-provided GhosttySelection without explicitly\n creating a GhosttyFormatter.\n\n Pass NULL for buf to query the required output size. In that case,\n out_written receives the required size and the function returns\n GHOSTTY_OUT_OF_SPACE.\n\n If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes\n the required size to out_written. The caller can then retry with a larger\n buffer.\n\n If options.selection is NULL and the terminal has no active selection, the\n function returns GHOSTTY_NO_VALUE.\n\n @param terminal The terminal to read from (must not be NULL)\n @param options Selection formatting options\n @param buf Output buffer, or NULL to query required size\n @param buf_len Length of buf in bytes\n @param out_written Number of bytes written, or required size on\n GHOSTTY_OUT_OF_SPACE (must not be NULL)\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_format_buf( + terminal: GhosttyTerminal, + options: GhosttyTerminalSelectionFormatOptions, + buf: *mut u8, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Format a terminal selection into an allocated buffer.\n\n This is a one-shot convenience API for formatting either the terminal's\n active selection or a caller-provided GhosttySelection without explicitly\n creating a GhosttyFormatter.\n\n The returned buffer is allocated using allocator, or the default allocator\n if NULL is passed. The caller owns the returned buffer and must free it with\n ghostty_free(), passing the same allocator and returned length.\n\n The returned bytes are not NUL-terminated. This supports plain text, VT, and\n HTML uniformly as byte output.\n\n If options.selection is NULL and the terminal has no active selection, the\n function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0.\n\n @param terminal The terminal to read from (must not be NULL)\n @param allocator Allocator used for the returned buffer, or NULL for the default allocator\n @param options Selection formatting options\n @param out_ptr Receives the allocated output buffer (must not be NULL)\n @param out_len Receives the output length in bytes (must not be NULL)\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_format_alloc( + terminal: GhosttyTerminal, + allocator: *const GhosttyAllocator, + options: GhosttyTerminalSelectionFormatOptions, + out_ptr: *mut *mut u8, + out_len: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Adjust a selection snapshot using terminal selection semantics.\n\n This mutates the caller-provided GhosttySelection in place. The logical end\n endpoint is always moved, regardless of whether the selection is forward or\n reversed visually. The input selection remains a snapshot: after adjustment,\n call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it\n as the terminal-owned selection if desired.\n\n The selection's start and end grid refs must both be valid untracked\n snapshots for the given terminal's currently active screen. In practice,\n they must come from that terminal and screen, and no mutating terminal call\n may have occurred since the refs were produced or reconstructed from\n tracked refs. Passing refs from another terminal, another screen, or stale\n refs violates this precondition.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param selection Selection snapshot to adjust in place\n @param adjustment The adjustment operation to apply\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,\n selection, or adjustment are invalid. Selection reference validity\n is a precondition and is not checked.\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_adjust( + terminal: GhosttyTerminal, + selection: *mut GhosttySelection, + adjustment: GhosttySelectionAdjust, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get the current endpoint ordering of a selection snapshot.\n\n The selection's start and end grid refs must both be valid untracked\n snapshots for the given terminal's currently active screen. In practice,\n they must come from that terminal and screen, and no mutating terminal call\n may have occurred since the refs were produced or reconstructed from\n tracked refs. Passing refs from another terminal, another screen, or stale\n refs violates this precondition.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param selection Selection snapshot to inspect\n @param[out] out_order On success, receives the selection order\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,\n selection, or output pointer are invalid. Selection reference\n validity is a precondition and is not checked.\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_order( + terminal: GhosttyTerminal, + selection: *const GhosttySelection, + out_order: *mut GhosttySelectionOrder, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Return a selection snapshot with endpoints ordered as requested.\n\n Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds,\n and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds.\n Mirrored desired orders are accepted but normalized the same as forward.\n The output selection is a fresh untracked snapshot and is not installed as\n the terminal's current selection.\n\n The selection's start and end grid refs must both be valid untracked\n snapshots for the given terminal's currently active screen. In practice,\n they must come from that terminal and screen, and no mutating terminal call\n may have occurred since the refs were produced or reconstructed from\n tracked refs. Passing refs from another terminal, another screen, or stale\n refs violates this precondition.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param selection Selection snapshot to order\n @param desired Desired endpoint order\n @param[out] out_selection On success, receives the ordered selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,\n selection, desired order, or output pointer are invalid. Selection\n reference validity is a precondition and is not checked.\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_ordered( + terminal: GhosttyTerminal, + selection: *const GhosttySelection, + desired: GhosttySelectionOrder, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Test whether a terminal point is inside a selection snapshot.\n\n This uses the same selection semantics as the terminal, including\n rectangular/block selections and linear selections spanning multiple rows.\n\n The selection's start and end grid refs must both be valid untracked\n snapshots for the given terminal's currently active screen. In practice,\n they must come from that terminal and screen, and no mutating terminal call\n may have occurred since the refs were produced or reconstructed from\n tracked refs. Passing refs from another terminal, another screen, or stale\n refs violates this precondition.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param selection Selection snapshot to inspect\n @param point Point to test for containment\n @param[out] out_contains On success, receives whether point is inside selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,\n selection, point, or output pointer are invalid. Selection reference\n validity is a precondition and is not checked.\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_contains( + terminal: GhosttyTerminal, + selection: *const GhosttySelection, + point: GhosttyPoint, + out_contains: *mut bool, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Test whether two selection snapshots are equal.\n\n Equality uses the terminal's internal selection semantics: both endpoint\n pins must match and both selections must have the same rectangular/block\n state. This avoids requiring callers to compare raw GhosttyGridRef internals.\n\n Both selections' start and end grid refs must be valid untracked snapshots\n for the given terminal's currently active screen. In practice, they must\n come from that terminal and screen, and no mutating terminal call may have\n occurred since the refs were produced or reconstructed from tracked refs.\n Passing refs from another terminal, another screen, or stale refs violates\n this precondition.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param a First selection snapshot to compare\n @param b Second selection snapshot to compare\n @param[out] out_equal On success, receives whether the selections are equal\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,\n selections, or output pointer are invalid. Selection reference\n validity is a precondition and is not checked.\n\n @ingroup selection"] + pub fn ghostty_terminal_selection_equal( + terminal: GhosttyTerminal, + a: *const GhosttySelection, + b: *const GhosttySelection, + out_equal: *mut bool, + ) -> GhosttyResult; +} +#[doc = " A packed 16-bit terminal mode.\n\n Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a\n single 16-bit integer. Use the inline helper functions to construct\n and inspect modes rather than manipulating bits directly."] +pub type GhosttyMode = u16; +#[doc = " Mode is not recognized"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_NOT_RECOGNIZED: GhosttyModeReportState = 0; +#[doc = " Mode is set (enabled)"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_SET: GhosttyModeReportState = 1; +#[doc = " Mode is reset (disabled)"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_RESET: GhosttyModeReportState = 2; +#[doc = " Mode is permanently set"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_PERMANENTLY_SET: GhosttyModeReportState = 3; +#[doc = " Mode is permanently reset"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_PERMANENTLY_RESET: GhosttyModeReportState = 4; +#[doc = " Mode is permanently reset"] +pub const GhosttyModeReportState_GHOSTTY_MODE_REPORT_MAX_VALUE: GhosttyModeReportState = 2147483647; +#[doc = " DECRPM report state values.\n\n These correspond to the Ps2 parameter in a DECRPM response\n sequence (CSI ? Ps1 ; Ps2 $ y)."] +pub type GhosttyModeReportState = ::std::os::raw::c_uint; +unsafe extern "C" { + #[doc = " Encode a DECRPM (DEC Private Mode Report) response sequence.\n\n Writes a mode report escape sequence into the provided buffer.\n The generated sequence has the form:\n - DEC private mode: CSI ? Ps1 ; Ps2 $ y\n - ANSI mode: CSI Ps1 ; Ps2 $ y\n\n If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE\n and writes the required buffer size to @p out_written. The caller can\n then retry with a sufficiently sized buffer.\n\n @param mode The mode identifying the mode to report on\n @param state The report state for this mode\n @param buf Output buffer to write the encoded sequence into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] + pub fn ghostty_mode_report_encode( + mode: GhosttyMode, + state: GhosttyModeReportState, + buf: *mut ::std::os::raw::c_char, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} +#[doc = " In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t"] +pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_MODE_2048: GhosttySizeReportStyle = 0; +#[doc = " XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t"] +pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_14_T: GhosttySizeReportStyle = 1; +#[doc = " XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t"] +pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_16_T: GhosttySizeReportStyle = 2; +#[doc = " XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t"] +pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_CSI_18_T: GhosttySizeReportStyle = 3; +#[doc = " XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t"] +pub const GhosttySizeReportStyle_GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE: GhosttySizeReportStyle = + 2147483647; +#[doc = " Size report style.\n\n Determines the output format for the terminal size report."] +pub type GhosttySizeReportStyle = ::std::os::raw::c_uint; +#[doc = " Terminal size information for encoding size reports."] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttySizeReportSize { + #[doc = " Terminal row count in cells."] + pub rows: u16, + #[doc = " Terminal column count in cells."] + pub columns: u16, + #[doc = " Width of a single terminal cell in pixels."] + pub cell_width: u32, + #[doc = " Height of a single terminal cell in pixels."] + pub cell_height: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySizeReportSize"][::std::mem::size_of::<GhosttySizeReportSize>() - 12usize]; + ["Alignment of GhosttySizeReportSize"] + [::std::mem::align_of::<GhosttySizeReportSize>() - 4usize]; + ["Offset of field: GhosttySizeReportSize::rows"] + [::std::mem::offset_of!(GhosttySizeReportSize, rows) - 0usize]; + ["Offset of field: GhosttySizeReportSize::columns"] + [::std::mem::offset_of!(GhosttySizeReportSize, columns) - 2usize]; + ["Offset of field: GhosttySizeReportSize::cell_width"] + [::std::mem::offset_of!(GhosttySizeReportSize, cell_width) - 4usize]; + ["Offset of field: GhosttySizeReportSize::cell_height"] + [::std::mem::offset_of!(GhosttySizeReportSize, cell_height) - 8usize]; +}; +unsafe extern "C" { + #[doc = " Encode a terminal size report into an escape sequence.\n\n Encodes a size report in the format specified by @p style into the\n provided buffer.\n\n If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE\n and writes the required buffer size to @p out_written. The caller can\n then retry with a sufficiently sized buffer.\n\n @param style The size report format to encode\n @param size Terminal size information\n @param buf Output buffer to write the encoded sequence into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] + pub fn ghostty_size_report_encode( + style: GhosttySizeReportStyle, + size: GhosttySizeReportSize, + buf: *mut ::std::os::raw::c_char, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} +#[doc = " Invalid / sentinel value."] +pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_INVALID: GhosttyKittyGraphicsData = + 0; +#[doc = " Populate a pre-allocated placement iterator with placement data from\n the storage. Iterator data is only valid as long as the underlying\n terminal is not mutated.\n\n Output type: GhosttyKittyGraphicsPlacementIterator *"] +pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR: + GhosttyKittyGraphicsData = 1; +#[doc = " Generation stamp of the last content mutation to this storage:\n any image transmit/replace, placement add, or delete. Zero means\n the storage has never been mutated (and is therefore empty).\n\n If the generation is unchanged since a previous query, the set of\n placements and all image data are identical, so placement iteration\n and image staleness checks can be skipped entirely. Note that\n placement *geometry* may still have changed (scrolling and resizing\n move placements without changing the storage contents), so rendering\n geometry such as ghostty_kitty_graphics_placement_render_info()\n must still be recomputed for frames marked dirty.\n\n Stamps are unique and monotonically increasing process-wide: a\n value observed from any storage never recurs for different content,\n even across screen switches (main vs. alternate screen have\n independent storages) or terminal resets. It is therefore safe to\n key caches on this value alone.\n\n Output type: uint64_t *"] +pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION: + GhosttyKittyGraphicsData = 2; +#[doc = " Generation stamp of the last content mutation to this storage:\n any image transmit/replace, placement add, or delete. Zero means\n the storage has never been mutated (and is therefore empty).\n\n If the generation is unchanged since a previous query, the set of\n placements and all image data are identical, so placement iteration\n and image staleness checks can be skipped entirely. Note that\n placement *geometry* may still have changed (scrolling and resizing\n move placements without changing the storage contents), so rendering\n geometry such as ghostty_kitty_graphics_placement_render_info()\n must still be recomputed for frames marked dirty.\n\n Stamps are unique and monotonically increasing process-wide: a\n value observed from any storage never recurs for different content,\n even across screen switches (main vs. alternate screen have\n independent storages) or terminal resets. It is therefore safe to\n key caches on this value alone.\n\n Output type: uint64_t *"] +pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE: GhosttyKittyGraphicsData = + 2147483647; +#[doc = " Queryable data kinds for ghostty_kitty_graphics_get().\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsData = ::std::os::raw::c_uint; +#[doc = " Invalid / sentinel value."] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID: + GhosttyKittyGraphicsPlacementData = 0; +#[doc = " The image ID this placement belongs to.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID: + GhosttyKittyGraphicsPlacementData = 1; +#[doc = " The placement ID.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID: + GhosttyKittyGraphicsPlacementData = 2; +#[doc = " Whether this is a virtual placement (unicode placeholder).\n\n Output type: bool *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL: + GhosttyKittyGraphicsPlacementData = 3; +#[doc = " Pixel offset from the left edge of the cell.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET: + GhosttyKittyGraphicsPlacementData = 4; +#[doc = " Pixel offset from the top edge of the cell.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET: + GhosttyKittyGraphicsPlacementData = 5; +#[doc = " Source rectangle x origin in pixels.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X: + GhosttyKittyGraphicsPlacementData = 6; +#[doc = " Source rectangle y origin in pixels.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y: + GhosttyKittyGraphicsPlacementData = 7; +#[doc = " Source rectangle width in pixels (0 = full image width).\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH: + GhosttyKittyGraphicsPlacementData = 8; +#[doc = " Source rectangle height in pixels (0 = full image height).\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT: + GhosttyKittyGraphicsPlacementData = 9; +#[doc = " Number of columns this placement occupies.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS: + GhosttyKittyGraphicsPlacementData = 10; +#[doc = " Number of rows this placement occupies.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS: + GhosttyKittyGraphicsPlacementData = 11; +#[doc = " Z-index for this placement.\n\n Output type: int32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z: + GhosttyKittyGraphicsPlacementData = 12; +#[doc = " Z-index for this placement.\n\n Output type: int32_t *"] +pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE: + GhosttyKittyGraphicsPlacementData = 2147483647; +#[doc = " Queryable data kinds for ghostty_kitty_graphics_placement_get().\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsPlacementData = ::std::os::raw::c_uint; +pub const GhosttyKittyPlacementLayer_GHOSTTY_KITTY_PLACEMENT_LAYER_ALL: GhosttyKittyPlacementLayer = + 0; +pub const GhosttyKittyPlacementLayer_GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG: + GhosttyKittyPlacementLayer = 1; +pub const GhosttyKittyPlacementLayer_GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT: + GhosttyKittyPlacementLayer = 2; +pub const GhosttyKittyPlacementLayer_GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT: + GhosttyKittyPlacementLayer = 3; +pub const GhosttyKittyPlacementLayer_GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE: + GhosttyKittyPlacementLayer = 2147483647; +#[doc = " Z-layer classification for kitty graphics placements.\n\n Based on the kitty protocol z-index conventions:\n - BELOW_BG: z < INT32_MIN/2 (drawn below cell background)\n - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text)\n - ABOVE_TEXT: z >= 0 (above text)\n - ALL: no filtering (current behavior)\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyPlacementLayer = ::std::os::raw::c_uint; +#[doc = " Set the z-layer filter for the iterator.\n\n Input type: GhosttyKittyPlacementLayer *"] +pub const GhosttyKittyGraphicsPlacementIteratorOption_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER : GhosttyKittyGraphicsPlacementIteratorOption = 0 ; +#[doc = " Set the z-layer filter for the iterator.\n\n Input type: GhosttyKittyPlacementLayer *"] +pub const GhosttyKittyGraphicsPlacementIteratorOption_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE : GhosttyKittyGraphicsPlacementIteratorOption = 2147483647 ; +#[doc = " Settable options for ghostty_kitty_graphics_placement_iterator_set().\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsPlacementIteratorOption = ::std::os::raw::c_uint; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGB: GhosttyKittyImageFormat = 0; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA: GhosttyKittyImageFormat = 1; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_PNG: GhosttyKittyImageFormat = 2; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA: GhosttyKittyImageFormat = + 3; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_GRAY: GhosttyKittyImageFormat = 4; +pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE: GhosttyKittyImageFormat = + 2147483647; +#[doc = " Pixel format of a Kitty graphics image.\n\n Note that stored images are always fully decoded:\n GHOSTTY_KITTY_IMAGE_FORMAT_PNG is never returned by\n ghostty_kitty_graphics_image_get() because PNG payloads are decoded\n to GHOSTTY_KITTY_IMAGE_FORMAT_RGBA before storage. The PNG value\n exists only for protocol-level completeness.\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyImageFormat = ::std::os::raw::c_uint; +pub const GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE: + GhosttyKittyImageCompression = 0; +pub const GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE: + GhosttyKittyImageCompression = 1; +pub const GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE: + GhosttyKittyImageCompression = 2147483647; +#[doc = " Compression of a Kitty graphics image.\n\n Note that stored images are always decompressed:\n GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE payloads are inflated\n before storage, so ghostty_kitty_graphics_image_get() always reports\n GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE. Consumers never need to\n inflate image data themselves.\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyImageCompression = ::std::os::raw::c_uint; +#[doc = " Invalid / sentinel value."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_INVALID: + GhosttyKittyGraphicsImageData = 0; +#[doc = " The image ID.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ID: GhosttyKittyGraphicsImageData = + 1; +#[doc = " The image number.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_NUMBER: + GhosttyKittyGraphicsImageData = 2; +#[doc = " Image width in pixels.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_WIDTH: + GhosttyKittyGraphicsImageData = 3; +#[doc = " Image height in pixels.\n\n Output type: uint32_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_HEIGHT: + GhosttyKittyGraphicsImageData = 4; +#[doc = " Pixel format of the image. Never GHOSTTY_KITTY_IMAGE_FORMAT_PNG;\n PNG payloads are decoded to RGBA before storage.\n\n Output type: GhosttyKittyImageFormat *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT: + GhosttyKittyGraphicsImageData = 5; +#[doc = " Compression of the image. Always\n GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are\n inflated before storage.\n\n Output type: GhosttyKittyImageCompression *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION: + GhosttyKittyGraphicsImageData = 6; +#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload this\n directly to the GPU without any decode step.\n\n Output type: const uint8_t **"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR: + GhosttyKittyGraphicsImageData = 7; +#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format.\n\n Output type: size_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN: + GhosttyKittyGraphicsImageData = 8; +#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers.\n\n Output type: uint64_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION: + GhosttyKittyGraphicsImageData = 9; +#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers.\n\n Output type: uint64_t *"] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE: + GhosttyKittyGraphicsImageData = 2147483647; +#[doc = " Queryable data kinds for ghostty_kitty_graphics_image_get().\n\n @ingroup kitty_graphics"] +pub type GhosttyKittyGraphicsImageData = ::std::os::raw::c_uint; +#[doc = " Combined rendering geometry for a placement in a single sized struct.\n\n Combines the results of ghostty_kitty_graphics_placement_pixel_size(),\n ghostty_kitty_graphics_placement_grid_size(),\n ghostty_kitty_graphics_placement_viewport_pos(), and\n ghostty_kitty_graphics_placement_source_rect() into one call. This is\n an optimization over calling those four functions individually,\n particularly useful in environments with high per-call overhead such\n as FFI or Cgo.\n\n This struct uses the sized-struct ABI pattern. Initialize with\n GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo) before calling\n ghostty_kitty_graphics_placement_render_info().\n\n @ingroup kitty_graphics"] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyKittyGraphicsPlacementRenderInfo { + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyKittyGraphicsPlacementRenderInfo)."] + pub size: usize, + #[doc = " Rendered width in pixels."] + pub pixel_width: u32, + #[doc = " Rendered height in pixels."] + pub pixel_height: u32, + #[doc = " Number of grid columns the placement occupies."] + pub grid_cols: u32, + #[doc = " Number of grid rows the placement occupies."] + pub grid_rows: u32, + #[doc = " Viewport-relative column (may be negative for partially visible placements)."] + pub viewport_col: i32, + #[doc = " Viewport-relative row (may be negative for partially visible placements)."] + pub viewport_row: i32, + #[doc = " False when the placement is fully off-screen or virtual."] + pub viewport_visible: bool, + #[doc = " Resolved source rectangle x origin in pixels."] + pub source_x: u32, + #[doc = " Resolved source rectangle y origin in pixels."] + pub source_y: u32, + #[doc = " Resolved source rectangle width in pixels."] + pub source_width: u32, + #[doc = " Resolved source rectangle height in pixels."] + pub source_height: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyKittyGraphicsPlacementRenderInfo"] + [::std::mem::size_of::<GhosttyKittyGraphicsPlacementRenderInfo>() - 56usize]; + ["Alignment of GhosttyKittyGraphicsPlacementRenderInfo"] + [::std::mem::align_of::<GhosttyKittyGraphicsPlacementRenderInfo>() - 8usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::size"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, size) - 0usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::pixel_width"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, pixel_width) - 8usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::pixel_height"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, pixel_height) - 12usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::grid_cols"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, grid_cols) - 16usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::grid_rows"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, grid_rows) - 20usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::viewport_col"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, viewport_col) - 24usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::viewport_row"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, viewport_row) - 28usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::viewport_visible"][::std::mem::offset_of!( + GhosttyKittyGraphicsPlacementRenderInfo, + viewport_visible + ) - 32usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::source_x"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, source_x) - 36usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::source_y"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, source_y) - 40usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::source_width"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, source_width) - 44usize]; + ["Offset of field: GhosttyKittyGraphicsPlacementRenderInfo::source_height"] + [::std::mem::offset_of!(GhosttyKittyGraphicsPlacementRenderInfo, source_height) - 48usize]; +}; +unsafe extern "C" { + #[doc = " Get data from a kitty graphics storage instance.\n\n The output pointer must be of the appropriate type for the requested\n data kind.\n\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n @param graphics The kitty graphics handle\n @param data The type of data to extract\n @param[out] out Pointer to store the extracted data\n @return GHOSTTY_SUCCESS on success\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_get( + graphics: GhosttyKittyGraphics, + data: GhosttyKittyGraphicsData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Look up a Kitty graphics image by its image ID.\n\n Returns NULL if no image with the given ID exists or if Kitty graphics\n are disabled at build time.\n\n @param graphics The kitty graphics handle\n @param image_id The image ID to look up\n @return An opaque image handle, or NULL if not found\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_image( + graphics: GhosttyKittyGraphics, + image_id: u32, + ) -> GhosttyKittyGraphicsImage; +} +unsafe extern "C" { + #[doc = " Get data from a Kitty graphics image.\n\n The output pointer must be of the appropriate type for the requested\n data kind.\n\n @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param data The data kind to query\n @param[out] out Pointer to receive the queried value\n @return GHOSTTY_SUCCESS on success\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_image_get( + image: GhosttyKittyGraphicsImage, + data: GhosttyKittyGraphicsImageData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get multiple data fields from a Kitty graphics image in a single call.\n\n This is an optimization over calling ghostty_kitty_graphics_image_get()\n repeatedly, particularly useful in environments with high per-call\n overhead such as FFI or Cgo.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n The type of each values[i] pointer must match the output type\n documented for keys[i].\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_image_get_multi( + image: GhosttyKittyGraphicsImage, + count: usize, + keys: *const GhosttyKittyGraphicsImageData, + values: *mut *mut ::std::os::raw::c_void, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Create a new placement iterator instance.\n\n All fields except the allocator are left undefined until populated\n via ghostty_kitty_graphics_get() with\n GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param[out] out_iterator On success, receives the created iterator handle\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation\n failure\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_iterator_new( + allocator: *const GhosttyAllocator, + out_iterator: *mut GhosttyKittyGraphicsPlacementIterator, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Free a placement iterator.\n\n @param iterator The iterator handle to free (may be NULL)\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_iterator_free( + iterator: GhosttyKittyGraphicsPlacementIterator, + ); +} +unsafe extern "C" { + #[doc = " Set an option on a placement iterator.\n\n Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a\n GhosttyKittyPlacementLayer value to filter placements by z-layer.\n The filter is applied during iteration: ghostty_kitty_graphics_placement_next()\n will skip placements that do not match the configured layer.\n\n The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering).\n\n @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param option The option to set\n @param value Pointer to the value (type depends on option; NULL returns\n GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_iterator_set( + iterator: GhosttyKittyGraphicsPlacementIterator, + option: GhosttyKittyGraphicsPlacementIteratorOption, + value: *const ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Advance the placement iterator to the next placement.\n\n If a layer filter has been set via\n ghostty_kitty_graphics_placement_iterator_set(), only placements\n matching that layer are returned.\n\n @param iterator The iterator handle (may be NULL)\n @return true if advanced to the next placement, false if at the end\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_next( + iterator: GhosttyKittyGraphicsPlacementIterator, + ) -> bool; +} +unsafe extern "C" { + #[doc = " Get data from the current placement in a placement iterator.\n\n Call ghostty_kitty_graphics_placement_next() at least once before\n calling this function.\n\n @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param data The data kind to query\n @param[out] out Pointer to receive the queried value\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the\n iterator is NULL or not positioned on a placement\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_get( + iterator: GhosttyKittyGraphicsPlacementIterator, + data: GhosttyKittyGraphicsPlacementData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get multiple data fields from the current placement in a single call.\n\n This is an optimization over calling ghostty_kitty_graphics_placement_get()\n repeatedly, particularly useful in environments with high per-call\n overhead such as FFI or Cgo.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n The type of each values[i] pointer must match the output type\n documented for keys[i].\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_get_multi( + iterator: GhosttyKittyGraphicsPlacementIterator, + count: usize, + keys: *const GhosttyKittyGraphicsPlacementData, + values: *mut *mut ::std::os::raw::c_void, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Compute the grid rectangle occupied by the current placement.\n\n Uses the placement's pin, the image dimensions, and the terminal's\n cell/pixel geometry to calculate the bounding rectangle. Virtual\n placements (unicode placeholders) return GHOSTTY_NO_VALUE.\n\n @param terminal The terminal handle\n @param image The image handle for this placement's image\n @param iterator The placement iterator positioned on a placement\n @param[out] out_selection On success, receives the bounding rectangle\n as a selection with rectangle=true\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle\n is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for\n virtual placements or when Kitty graphics are disabled\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_rect( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Compute the rendered pixel size of the current placement.\n\n Takes into account the placement's source rectangle, specified\n columns/rows, and aspect ratio to calculate the final rendered\n pixel dimensions.\n\n @param iterator The placement iterator positioned on a placement\n @param image The image handle for this placement's image\n @param terminal The terminal handle\n @param[out] out_width On success, receives the width in pixels\n @param[out] out_height On success, receives the height in pixels\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle\n is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when\n Kitty graphics are disabled\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_pixel_size( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal, + out_width: *mut u32, + out_height: *mut u32, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Compute the grid cell size of the current placement.\n\n Returns the number of columns and rows that the placement occupies\n in the terminal grid. If the placement specifies explicit columns\n and rows, those are returned directly; otherwise they are calculated\n from the pixel size and cell dimensions.\n\n @param iterator The placement iterator positioned on a placement\n @param image The image handle for this placement's image\n @param terminal The terminal handle\n @param[out] out_cols On success, receives the number of columns\n @param[out] out_rows On success, receives the number of rows\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle\n is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when\n Kitty graphics are disabled\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_grid_size( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal, + out_cols: *mut u32, + out_rows: *mut u32, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get the viewport-relative grid position of the current placement.\n\n Converts the placement's internal pin to viewport-relative column and\n row coordinates. The returned coordinates represent the top-left\n corner of the placement in the viewport's grid coordinate space.\n\n The row value can be negative when the placement's origin has\n scrolled above the top of the viewport. For example, a 4-row\n image that has scrolled up by 2 rows returns row=-2, meaning\n its top 2 rows are above the visible area but its bottom 2 rows\n are still on screen. Embedders should use these coordinates\n directly when computing the destination rectangle for rendering;\n the embedder is responsible for clipping the portion of the image\n that falls outside the viewport.\n\n Returns GHOSTTY_SUCCESS for any placement that is at least\n partially visible in the viewport. Returns GHOSTTY_NO_VALUE when\n the placement is completely outside the viewport (its bottom edge\n is above the viewport or its top edge is at or below the last\n viewport row), or when the placement is a virtual (unicode\n placeholder) placement.\n\n @param iterator The placement iterator positioned on a placement\n @param image The image handle for this placement's image\n @param terminal The terminal handle\n @param[out] out_col On success, receives the viewport-relative column\n @param[out] out_row On success, receives the viewport-relative row\n (may be negative for partially visible placements)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully\n off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle\n is NULL or the iterator is not positioned\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_viewport_pos( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal, + out_col: *mut i32, + out_row: *mut i32, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get the resolved source rectangle for the current placement.\n\n Applies kitty protocol semantics: a width or height of 0 in the\n placement means \"use the full image dimension\", and the resulting\n rectangle is clamped to the actual image bounds. The returned\n values are in pixels and are ready to use for texture sampling.\n\n @param iterator The placement iterator positioned on a placement\n @param image The image handle for this placement's image\n @param[out] out_x Source rect x origin in pixels\n @param[out] out_y Source rect y origin in pixels\n @param[out] out_width Source rect width in pixels\n @param[out] out_height Source rect height in pixels\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any\n handle is NULL or the iterator is not positioned\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_source_rect( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + out_x: *mut u32, + out_y: *mut u32, + out_width: *mut u32, + out_height: *mut u32, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Get all rendering geometry for a placement in a single call.\n\n Combines pixel size, grid size, viewport position, and source\n rectangle into one struct. Initialize with\n GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo).\n\n When viewport_visible is false, the placement is fully off-screen\n or is a virtual placement; viewport_col and viewport_row may\n contain meaningless values in that case.\n\n @param iterator The iterator positioned on a placement\n @param image The image handle for this placement's image\n @param terminal The terminal handle\n @param[out] out_info Pointer to receive the rendering geometry\n @return GHOSTTY_SUCCESS on success\n\n @ingroup kitty_graphics"] + pub fn ghostty_kitty_graphics_placement_render_info( + iterator: GhosttyKittyGraphicsPlacementIterator, + image: GhosttyKittyGraphicsImage, + terminal: GhosttyTerminal, + out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo, + ) -> GhosttyResult; +} #[doc = " Terminal initialization options.\n\n @ingroup terminal"] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] @@ -927,6 +2118,31 @@ const _: () = { ["Offset of field: GhosttyTerminalOptions::max_scrollback"] [::std::mem::offset_of!(GhosttyTerminalOptions, max_scrollback) - 8usize]; }; +#[doc = " Perform one bounded compression step suitable for idle scheduling."] +pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL: + GhosttyTerminalCompressionMode = 0; +#[doc = " Synchronously inspect every currently eligible page."] +pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL: + GhosttyTerminalCompressionMode = 1; +#[doc = " Synchronously inspect every currently eligible page."] +pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE: + GhosttyTerminalCompressionMode = 2147483647; +#[doc = " Amount of compression work to perform before returning.\n\n @ingroup terminal"] +pub type GhosttyTerminalCompressionMode = ::std::os::raw::c_uint; +#[doc = " Retained-mapping reclamation is unavailable on this target."] +pub const GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED: + GhosttyTerminalCompressionResult = 0; +#[doc = " More incremental compression work remains."] +pub const GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING: + GhosttyTerminalCompressionResult = 1; +#[doc = " The pass has no continuation to schedule."] +pub const GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE: + GhosttyTerminalCompressionResult = 2; +#[doc = " The pass has no continuation to schedule."] +pub const GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE: + GhosttyTerminalCompressionResult = 2147483647; +#[doc = " Scheduling result from terminal compression.\n\n @ingroup terminal"] +pub type GhosttyTerminalCompressionResult = ::std::os::raw::c_uint; #[doc = " Scroll to the top of the scrollback."] pub const GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_TOP: GhosttyTerminalScrollViewportTag = 0; @@ -936,6 +2152,12 @@ pub const GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM: #[doc = " Scroll by a delta amount (up is negative)."] pub const GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_DELTA: GhosttyTerminalScrollViewportTag = 2; +#[doc = " Scroll to an absolute row offset from the top of the scrollable\n area. Row 0 is the top of the scrollback and the requested row\n becomes the first visible row of the viewport. The value is\n clamped so the viewport never scrolls beyond the top of the\n active area. If the terminal has no scrollback (e.g. the\n alternate screen is active), the viewport always remains on the\n active area.\n\n This is the same row space as the offset field of\n GhosttyTerminalScrollbar, so a scrollbar position obtained from\n GHOSTTY_TERMINAL_DATA_SCROLLBAR round-trips cleanly."] +pub const GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_ROW: + GhosttyTerminalScrollViewportTag = 3; +#[doc = " Scroll to an absolute row offset from the top of the scrollable\n area. Row 0 is the top of the scrollback and the requested row\n becomes the first visible row of the viewport. The value is\n clamped so the viewport never scrolls beyond the top of the\n active area. If the terminal has no scrollback (e.g. the\n alternate screen is active), the viewport always remains on the\n active area.\n\n This is the same row space as the offset field of\n GhosttyTerminalScrollbar, so a scrollbar position obtained from\n GHOSTTY_TERMINAL_DATA_SCROLLBAR round-trips cleanly."] +pub const GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE: + GhosttyTerminalScrollViewportTag = 2147483647; #[doc = " Scroll viewport behavior tag.\n\n @ingroup terminal"] pub type GhosttyTerminalScrollViewportTag = ::std::os::raw::c_uint; #[doc = " Scroll viewport value.\n\n @ingroup terminal"] @@ -944,6 +2166,8 @@ pub type GhosttyTerminalScrollViewportTag = ::std::os::raw::c_uint; pub union GhosttyTerminalScrollViewportValue { #[doc = " Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative."] pub delta: isize, + #[doc = " Absolute row offset (only used with GHOSTTY_SCROLL_VIEWPORT_ROW)."] + pub row: usize, #[doc = " Padding for ABI compatibility. Do not use."] pub _padding: [u64; 2usize], } @@ -955,6 +2179,8 @@ const _: () = { [::std::mem::align_of::<GhosttyTerminalScrollViewportValue>() - 8usize]; ["Offset of field: GhosttyTerminalScrollViewportValue::delta"] [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, delta) - 0usize]; + ["Offset of field: GhosttyTerminalScrollViewportValue::row"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, row) - 0usize]; ["Offset of field: GhosttyTerminalScrollViewportValue::_padding"] [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, _padding) - 0usize]; }; @@ -998,8 +2224,28 @@ impl Default for GhosttyTerminalScrollViewport { pub const GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_PRIMARY: GhosttyTerminalScreen = 0; #[doc = " The alternate screen."] pub const GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_ALTERNATE: GhosttyTerminalScreen = 1; +#[doc = " The alternate screen."] +pub const GhosttyTerminalScreen_GHOSTTY_TERMINAL_SCREEN_MAX_VALUE: GhosttyTerminalScreen = + 2147483647; #[doc = " Terminal screen identifier.\n\n Identifies which screen buffer is active in the terminal.\n\n @ingroup terminal"] pub type GhosttyTerminalScreen = ::std::os::raw::c_uint; +#[doc = " Bar cursor (DECSCUSR 5, 6)."] +pub const GhosttyTerminalCursorStyle_GHOSTTY_TERMINAL_CURSOR_STYLE_BAR: GhosttyTerminalCursorStyle = + 0; +#[doc = " Block cursor (DECSCUSR 1, 2)."] +pub const GhosttyTerminalCursorStyle_GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK: + GhosttyTerminalCursorStyle = 1; +#[doc = " Underline cursor (DECSCUSR 3, 4)."] +pub const GhosttyTerminalCursorStyle_GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE: + GhosttyTerminalCursorStyle = 2; +#[doc = " Hollow block cursor."] +pub const GhosttyTerminalCursorStyle_GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW: + GhosttyTerminalCursorStyle = 3; +#[doc = " Hollow block cursor."] +pub const GhosttyTerminalCursorStyle_GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE: + GhosttyTerminalCursorStyle = 2147483647; +#[doc = " Visual style of the terminal cursor.\n\n @ingroup terminal"] +pub type GhosttyTerminalCursorStyle = ::std::os::raw::c_uint; #[doc = " Scrollbar state for the terminal viewport.\n\n Represents the scrollable area dimensions needed to render a scrollbar.\n\n @ingroup terminal"] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] @@ -1026,12 +2272,120 @@ const _: () = { }; #[doc = " Callback function type for bell.\n\n Called when the terminal receives a BEL character (0x07).\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n\n @ingroup terminal"] pub type GhosttyTerminalBellFn = ::std::option::Option< - unsafe extern "C" fn(terminal: GhosttyTerminal_ptr, userdata: *mut ::std::os::raw::c_void), + unsafe extern "C" fn(terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void), +>; +#[doc = " The standard system clipboard."] +pub const GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD: GhosttyClipboardLocation = + 0; +#[doc = " The selection clipboard."] +pub const GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_SELECTION: GhosttyClipboardLocation = + 1; +#[doc = " The primary selection clipboard."] +pub const GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_PRIMARY: GhosttyClipboardLocation = 2; +#[doc = " The primary selection clipboard."] +pub const GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_MAX_VALUE: GhosttyClipboardLocation = + 2147483647; +#[doc = " Clipboard destination for a clipboard write.\n\n Protocol-specific destination identifiers are normalized to these values\n before the clipboard write callback is invoked.\n\n @ingroup terminal"] +pub type GhosttyClipboardLocation = ::std::os::raw::c_uint; +#[doc = " One MIME representation in a clipboard write.\n\n Both strings are borrowed and valid only for the duration of the callback.\n The data is binary-safe and has already been decoded from any protocol-level\n encoding. A zero-length data string is an explicit empty representation; it\n does not clear the clipboard.\n\n This struct has a frozen layout and will not gain fields in future versions.\n\n @ingroup terminal"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyClipboardContent { + #[doc = " MIME type of the representation."] + pub mime: GhosttyString, + #[doc = " Decoded, binary-safe representation data."] + pub data: GhosttyString, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyClipboardContent"][::std::mem::size_of::<GhosttyClipboardContent>() - 32usize]; + ["Alignment of GhosttyClipboardContent"] + [::std::mem::align_of::<GhosttyClipboardContent>() - 8usize]; + ["Offset of field: GhosttyClipboardContent::mime"] + [::std::mem::offset_of!(GhosttyClipboardContent, mime) - 0usize]; + ["Offset of field: GhosttyClipboardContent::data"] + [::std::mem::offset_of!(GhosttyClipboardContent, data) - 16usize]; +}; +impl Default for GhosttyClipboardContent { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " A semantic, atomic clipboard write.\n\n This is a sized struct. The callback must only access fields present in the\n size reported by `size`. The request, contents array, MIME strings, and\n data strings are all borrowed and valid only for the callback duration.\n\n All entries in `contents` are representations of the same logical value\n and must be committed atomically. A `contents_len` of zero requests that\n the destination be cleared. This is distinct from a content entry whose data\n has zero length.\n\n @ingroup terminal"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyClipboardWrite { + #[doc = " Size of this struct in bytes."] + pub size: usize, + #[doc = " Clipboard destination."] + pub location: GhosttyClipboardLocation, + #[doc = " Borrowed array of MIME representations."] + pub contents: *const GhosttyClipboardContent, + #[doc = " Number of entries in contents; zero means clear the destination."] + pub contents_len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyClipboardWrite"][::std::mem::size_of::<GhosttyClipboardWrite>() - 32usize]; + ["Alignment of GhosttyClipboardWrite"] + [::std::mem::align_of::<GhosttyClipboardWrite>() - 8usize]; + ["Offset of field: GhosttyClipboardWrite::size"] + [::std::mem::offset_of!(GhosttyClipboardWrite, size) - 0usize]; + ["Offset of field: GhosttyClipboardWrite::location"] + [::std::mem::offset_of!(GhosttyClipboardWrite, location) - 8usize]; + ["Offset of field: GhosttyClipboardWrite::contents"] + [::std::mem::offset_of!(GhosttyClipboardWrite, contents) - 16usize]; + ["Offset of field: GhosttyClipboardWrite::contents_len"] + [::std::mem::offset_of!(GhosttyClipboardWrite, contents_len) - 24usize]; +}; +impl Default for GhosttyClipboardWrite { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " The clipboard write completed successfully."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS: + GhosttyClipboardWriteResult = 0; +#[doc = " The clipboard write was denied by policy or the user."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED: + GhosttyClipboardWriteResult = 1; +#[doc = " The destination or one or more representations are unsupported."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED: + GhosttyClipboardWriteResult = 2; +#[doc = " The clipboard is temporarily unavailable."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY: + GhosttyClipboardWriteResult = 3; +#[doc = " One or more representations contain invalid data."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA: + GhosttyClipboardWriteResult = 4; +#[doc = " The clipboard write failed due to an I/O error."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR: + GhosttyClipboardWriteResult = 5; +#[doc = " The clipboard write failed due to an I/O error."] +pub const GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE: + GhosttyClipboardWriteResult = 2147483647; +#[doc = " Result of a clipboard write callback.\n\n Protocols without write acknowledgements, including OSC 52 and iTerm2\n OSC 1337 Copy, ignore this result.\n\n @ingroup terminal"] +pub type GhosttyClipboardWriteResult = ::std::os::raw::c_uint; +#[doc = " Callback function type for clipboard_write.\n\n Called synchronously for a complete logical clipboard write. Protocol\n details such as OSC 52 selectors, base64 encoding, multipart chunks,\n aliases, and terminators are normalized before this callback is invoked.\n OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback\n shape. OSC 52 clipboard read requests (\"?\") are always ignored and never\n forwarded to this callback.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @param write Borrowed atomic clipboard write request\n @return The result of attempting the clipboard write\n\n @ingroup terminal"] +pub type GhosttyTerminalClipboardWriteFn = ::std::option::Option< + unsafe extern "C" fn( + terminal: GhosttyTerminal, + userdata: *mut ::std::os::raw::c_void, + write: *const GhosttyClipboardWrite, + ) -> GhosttyClipboardWriteResult, >; #[doc = " Callback function type for color scheme queries (CSI ? 996 n).\n\n Called when the terminal receives a color scheme device status report\n query. Return true and fill *out_scheme with the current color scheme,\n or return false to silently ignore the query.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @param[out] out_scheme Pointer to store the current color scheme\n @return true if the color scheme was filled, false to ignore the query\n\n @ingroup terminal"] pub type GhosttyTerminalColorSchemeFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, out_scheme: *mut GhosttyColorScheme, ) -> bool, @@ -1039,7 +2393,7 @@ pub type GhosttyTerminalColorSchemeFn = ::std::option::Option< #[doc = " Callback function type for device attributes queries (DA1/DA2/DA3).\n\n Called when the terminal receives a device attributes query (CSI c,\n CSI > c, or CSI = c). Return true and fill *out_attrs with the\n response data, or return false to silently ignore the query.\n\n The terminal uses whichever sub-struct (primary, secondary, tertiary)\n matches the request type, but all three should be filled for simplicity.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @param[out] out_attrs Pointer to store the device attributes response\n @return true if attributes were filled, false to ignore the query\n\n @ingroup terminal"] pub type GhosttyTerminalDeviceAttributesFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, out_attrs: *mut GhosttyDeviceAttributes, ) -> bool, @@ -1047,26 +2401,30 @@ pub type GhosttyTerminalDeviceAttributesFn = ::std::option::Option< #[doc = " Callback function type for enquiry (ENQ, 0x05).\n\n Called when the terminal receives an ENQ character. Return the\n response bytes as a GhosttyString. The memory must remain valid\n until the callback returns. Return a zero-length string to send\n no response.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @return The response bytes to write back to the pty\n\n @ingroup terminal"] pub type GhosttyTerminalEnquiryFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, ) -> GhosttyString, >; #[doc = " Callback function type for size queries (XTWINOPS).\n\n Called in response to XTWINOPS size queries (CSI 14/16/18 t).\n Return true and fill *out_size with the current terminal geometry,\n or return false to silently ignore the query.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @param[out] out_size Pointer to store the terminal size information\n @return true if size was filled, false to ignore the query\n\n @ingroup terminal"] pub type GhosttyTerminalSizeFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, out_size: *mut GhosttySizeReportSize, ) -> bool, >; #[doc = " Callback function type for title_changed.\n\n Called when the terminal title changes via escape sequences\n (e.g. OSC 0 or OSC 2). The new title can be queried from the\n terminal after the callback returns.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n\n @ingroup terminal"] pub type GhosttyTerminalTitleChangedFn = ::std::option::Option< - unsafe extern "C" fn(terminal: GhosttyTerminal_ptr, userdata: *mut ::std::os::raw::c_void), + unsafe extern "C" fn(terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void), +>; +#[doc = " Callback function type for pwd_changed.\n\n Called when the terminal pwd (current working directory) changes via\n escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or\n OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with\n GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value.\n\n The terminal stores whatever bytes the shell emitted, without parsing.\n That means for OSC 7 the value is the raw URI (typically file://...);\n for OSC 9/OSC 1337 it is typically a bare path. The embedder is\n responsible for decoding any URI scheme or host if it cares about them.\n\n The callback also fires when the shell clears the pwd (e.g. an empty\n OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length\n string.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n\n @ingroup terminal"] +pub type GhosttyTerminalPwdChangedFn = ::std::option::Option< + unsafe extern "C" fn(terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void), >; #[doc = " Callback function type for write_pty.\n\n Called when the terminal needs to write data back to the pty, for\n example in response to a device status report or mode query. The\n data is only valid for the duration of the call; callers must copy\n it if it needs to persist.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @param data Pointer to the response bytes\n @param len Length of the response in bytes\n\n @ingroup terminal"] pub type GhosttyTerminalWritePtyFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, data: *const u8, len: usize, @@ -1075,7 +2433,7 @@ pub type GhosttyTerminalWritePtyFn = ::std::option::Option< #[doc = " Callback function type for XTVERSION.\n\n Called when the terminal receives an XTVERSION query (CSI > q).\n Return the version string (e.g. \"myterm 1.0\") as a GhosttyString.\n The memory must remain valid until the callback returns. Return a\n zero-length string to report the default \"libghostty\" version.\n\n @param terminal The terminal handle\n @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA\n @return The version string to report\n\n @ingroup terminal"] pub type GhosttyTerminalXtversionFn = ::std::option::Option< unsafe extern "C" fn( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void, ) -> GhosttyString, >; @@ -1101,6 +2459,47 @@ pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES: GhosttyT pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_TITLE: GhosttyTerminalOption = 9; #[doc = " Set the terminal working directory manually.\n\n The string data is copied into the terminal. A NULL value pointer\n clears the pwd (equivalent to setting an empty string).\n\n Input type: GhosttyString*"] pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD: GhosttyTerminalOption = 10; +#[doc = " Set the default foreground color.\n\n A NULL value pointer clears the default (unset).\n\n Input type: GhosttyColorRgb*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND: GhosttyTerminalOption = 11; +#[doc = " Set the default background color.\n\n A NULL value pointer clears the default (unset).\n\n Input type: GhosttyColorRgb*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND: GhosttyTerminalOption = 12; +#[doc = " Set the default cursor color.\n\n A NULL value pointer clears the default (unset).\n\n Input type: GhosttyColorRgb*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_CURSOR: GhosttyTerminalOption = 13; +#[doc = " Set the default 256-color palette.\n\n The value must point to an array of exactly 256 GhosttyColorRgb values.\n A NULL value pointer resets to the built-in default palette.\n\n Input type: GhosttyColorRgb[256]*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_COLOR_PALETTE: GhosttyTerminalOption = 14; +#[doc = " Set the Kitty image storage limit in bytes.\n\n Applied to all initialized screens (primary and alternate).\n A value of zero disables the Kitty graphics protocol entirely,\n deleting all stored images and placements. A NULL value pointer\n is equivalent to zero (disables). Has no effect when Kitty graphics\n are disabled at build time.\n\n Input type: uint64_t*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT: + GhosttyTerminalOption = 15; +#[doc = " Enable or disable Kitty image loading via the file medium.\n\n A NULL value pointer is a no-op. Has no effect when Kitty graphics\n are disabled at build time.\n\n Input type: bool*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE: + GhosttyTerminalOption = 16; +#[doc = " Enable or disable Kitty image loading via the temporary file medium.\n\n A NULL value pointer is a no-op. Has no effect when Kitty graphics\n are disabled at build time.\n\n Input type: bool*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE: + GhosttyTerminalOption = 17; +#[doc = " Enable or disable Kitty image loading via the shared memory medium.\n\n A NULL value pointer is a no-op. Has no effect when Kitty graphics\n are disabled at build time.\n\n Input type: bool*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM: + GhosttyTerminalOption = 18; +#[doc = " Set the maximum bytes the APC handler will buffer for all protocols.\n This prevents malicious input from causing unbounded memory allocation.\n A NULL value pointer removes all overrides, reverting to the built-in\n defaults.\n\n Input type: size_t*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES: GhosttyTerminalOption = 19; +#[doc = " Set the maximum bytes the APC handler will buffer for Kitty graphics\n protocol data. A NULL value pointer removes the override, reverting\n to the built-in default.\n\n Input type: size_t*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY: GhosttyTerminalOption = + 20; +#[doc = " Set the active screen selection.\n\n The value must point to a GhosttySelection whose grid references are\n valid for this terminal's active screen at the time of the call. The\n terminal copies the selection immediately and converts it to\n terminal-owned tracked state, so the GhosttySelection struct and its\n untracked grid references do not need to outlive this call.\n\n Passing NULL clears the active screen selection.\n\n Input type: GhosttySelection*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_SELECTION: GhosttyTerminalOption = 21; +#[doc = " Set the default cursor style used by DECSCUSR reset (CSI 0 q).\n\n A NULL value pointer resets to the built-in default block cursor.\n\n Input type: GhosttyTerminalCursorStyle*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE: GhosttyTerminalOption = + 22; +#[doc = " Set whether the default cursor should blink when reset by DECSCUSR\n (CSI 0 q).\n\n A NULL value pointer resets to the built-in default of not blinking.\n\n Input type: bool*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK: GhosttyTerminalOption = + 23; +#[doc = " Enable or disable Glyph Protocol APC handling.\n\n When disabled, Glyph Protocol APC sequences are ignored and no\n support/query/register/clear responses are emitted. Disabling also clears\n the terminal session's glyph glossary. A NULL value pointer is a no-op.\n\n Input type: bool*"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL: GhosttyTerminalOption = 24; +#[doc = " Callback invoked when the terminal pwd changes via escape\n sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL\n to ignore pwd change events.\n\n Input type: GhosttyTerminalPwdChangedFn"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD_CHANGED: GhosttyTerminalOption = 25; +#[doc = " Callback invoked when the running program performs a clipboard write.\n OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set\n of decoded MIME representations. Set to NULL to ignore clipboard writes.\n Clipboard read requests are always ignored; see\n GhosttyTerminalClipboardWriteFn.\n\n Input type: GhosttyTerminalClipboardWriteFn"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE: GhosttyTerminalOption = 26; +#[doc = " Callback invoked when the running program performs a clipboard write.\n OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set\n of decoded MIME representations. Set to NULL to ignore clipboard writes.\n Clipboard read requests are always ignored; see\n GhosttyTerminalClipboardWriteFn.\n\n Input type: GhosttyTerminalClipboardWriteFn"] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647; #[doc = " Terminal option identifiers.\n\n These values are used with ghostty_terminal_set() to configure\n terminal callbacks and associated state.\n\n @ingroup terminal"] pub type GhosttyTerminalOption = ::std::os::raw::c_uint; #[doc = " Invalid data type. Never results in any data extraction."] @@ -1121,7 +2520,7 @@ pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN: GhosttyTermin pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE: GhosttyTerminalData = 7; #[doc = " Current Kitty keyboard protocol flags.\n\n Output type: GhosttyKittyKeyFlags * (uint8_t *)"] pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS: GhosttyTerminalData = 8; -#[doc = " Scrollbar state for the terminal viewport.\n\n This may be expensive to calculate depending on where the viewport\n is (arbitrary pins are expensive). The caller should take care to only\n call this as needed and not too frequently.\n\n Output type: GhosttyTerminalScrollbar *"] +#[doc = " Scrollbar state for the terminal viewport.\n\n This is amortized O(1): the total is maintained incrementally as\n the terminal is modified and the viewport offset is cached. The\n first read after the viewport moves to an arbitrary position that\n isn't an absolute row (e.g. scrolling to a selection) may cost\n O(pages) to compute the offset, after which it is cached again.\n\n There is intentionally no change notification for scroll state.\n Callers building scrollbars should poll this once per frame or\n per write batch and diff the result to detect changes; this is\n what Ghostty's own renderer does.\n\n Output type: GhosttyTerminalScrollbar *"] pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SCROLLBAR: GhosttyTerminalData = 9; #[doc = " The current SGR style of the cursor.\n\n This is the style that will be applied to newly printed characters.\n\n Output type: GhosttyStyle *"] pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_CURSOR_STYLE: GhosttyTerminalData = 10; @@ -1139,28 +2538,66 @@ pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS: GhosttyTerm pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX: GhosttyTerminalData = 16; #[doc = " The total height of the terminal in pixels.\n\n This is rows * cell_height_px as set by ghostty_terminal_resize().\n\n Output type: uint32_t *"] pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX: GhosttyTerminalData = 17; +#[doc = " The effective foreground color (override or default).\n\n Returns GHOSTTY_NO_VALUE if no foreground color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND: GhosttyTerminalData = 18; +#[doc = " The effective background color (override or default).\n\n Returns GHOSTTY_NO_VALUE if no background color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND: GhosttyTerminalData = 19; +#[doc = " The effective cursor color (override or default).\n\n Returns GHOSTTY_NO_VALUE if no cursor color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_CURSOR: GhosttyTerminalData = 20; +#[doc = " The current 256-color palette.\n\n Output type: GhosttyColorRgb[256] *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_PALETTE: GhosttyTerminalData = 21; +#[doc = " The default foreground color (ignoring any OSC override).\n\n Returns GHOSTTY_NO_VALUE if no default foreground color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT: GhosttyTerminalData = + 22; +#[doc = " The default background color (ignoring any OSC override).\n\n Returns GHOSTTY_NO_VALUE if no default background color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT: GhosttyTerminalData = + 23; +#[doc = " The default cursor color (ignoring any OSC override).\n\n Returns GHOSTTY_NO_VALUE if no default cursor color is set.\n\n Output type: GhosttyColorRgb *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT: GhosttyTerminalData = 24; +#[doc = " The default 256-color palette (ignoring any OSC overrides).\n\n Output type: GhosttyColorRgb[256] *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT: GhosttyTerminalData = 25; +#[doc = " The Kitty image storage limit in bytes for the active screen.\n\n A value of zero means the Kitty graphics protocol is disabled.\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n Output type: uint64_t *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT: GhosttyTerminalData = + 26; +#[doc = " Whether the file medium is enabled for Kitty image loading on the\n active screen.\n\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE: GhosttyTerminalData = + 27; +#[doc = " Whether the temporary file medium is enabled for Kitty image loading\n on the active screen.\n\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE: + GhosttyTerminalData = 28; +#[doc = " Whether the shared memory medium is enabled for Kitty image loading\n on the active screen.\n\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM: + GhosttyTerminalData = 29; +#[doc = " The Kitty graphics image storage for the active screen.\n\n Returns a borrowed pointer to the image storage. The pointer is valid\n until the next mutating terminal call (e.g. ghostty_terminal_vt_write()\n or ghostty_terminal_reset()).\n\n Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.\n\n Output type: GhosttyKittyGraphics *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS: GhosttyTerminalData = 30; +#[doc = " The active screen's current selection.\n\n On success, writes an untracked snapshot of the terminal-owned selection\n to the caller-provided GhosttySelection. The GhosttySelection struct is\n caller-owned and may be kept, but the grid references inside it are\n untracked borrowed references into the active screen. They are only valid\n until the next mutating terminal call, such as ghostty_terminal_set(),\n ghostty_terminal_vt_write(), ghostty_terminal_resize(), or\n ghostty_terminal_reset().\n\n Returns GHOSTTY_NO_VALUE when there is no active selection.\n\n Output type: GhosttySelection *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SELECTION: GhosttyTerminalData = 31; +#[doc = " Whether the viewport is currently pinned to the active area.\n\n This is true when the viewport is following the active terminal area,\n and false when the user has scrolled into history.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE: GhosttyTerminalData = 32; +#[doc = " Whether the viewport is currently pinned to the active area.\n\n This is true when the viewport is following the active terminal area,\n and false when the user has scrolled into history.\n\n Output type: bool *"] +pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_MAX_VALUE: GhosttyTerminalData = 2147483647; #[doc = " Terminal data types.\n\n These values specify what type of data to extract from a terminal\n using `ghostty_terminal_get`.\n\n @ingroup terminal"] pub type GhosttyTerminalData = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Create a new terminal instance.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param terminal Pointer to store the created terminal handle\n @param options Terminal initialization options\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup terminal"] pub fn ghostty_terminal_new( allocator: *const GhosttyAllocator, - terminal: *mut GhosttyTerminal_ptr, + terminal: *mut GhosttyTerminal, options: GhosttyTerminalOptions, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a terminal instance.\n\n Releases all resources associated with the terminal. After this call,\n the terminal handle becomes invalid and must not be used.\n\n @param terminal The terminal handle to free (may be NULL)\n\n @ingroup terminal"] - pub fn ghostty_terminal_free(terminal: GhosttyTerminal_ptr); + pub fn ghostty_terminal_free(terminal: GhosttyTerminal); } unsafe extern "C" { #[doc = " Perform a full reset of the terminal (RIS).\n\n Resets all terminal state back to its initial configuration, including\n modes, scrollback, scrolling region, and screen contents. The terminal\n dimensions are preserved.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n\n @ingroup terminal"] - pub fn ghostty_terminal_reset(terminal: GhosttyTerminal_ptr); + pub fn ghostty_terminal_reset(terminal: GhosttyTerminal); } unsafe extern "C" { #[doc = " Resize the terminal to the given dimensions.\n\n Changes the number of columns and rows in the terminal. The primary\n screen will reflow content if wraparound mode is enabled; the alternate\n screen does not reflow. If the dimensions are unchanged, this is a no-op.\n\n This also updates the terminal's pixel dimensions (used for image\n protocols and size reports), disables synchronized output mode (allowed\n by the spec so that resize results are shown immediately), and sends an\n in-band size report if mode 2048 is enabled.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param cols New width in cells (must be greater than zero)\n @param rows New height in cells (must be greater than zero)\n @param cell_width_px Width of a single cell in pixels\n @param cell_height_px Height of a single cell in pixels\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup terminal"] pub fn ghostty_terminal_resize( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, cols: u16, rows: u16, cell_width_px: u32, @@ -1170,26 +2607,41 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set an option on the terminal.\n\n Configures terminal callbacks and associated state such as the\n write_pty callback and userdata pointer. The value is passed\n directly for pointer types (callbacks, userdata) or as a pointer\n to the value for non-pointer types (e.g. GhosttyString*).\n NULL clears the option to its default.\n\n Callbacks are invoked synchronously during ghostty_terminal_vt_write().\n Callbacks must not call ghostty_terminal_vt_write() on the same\n terminal (no reentrancy).\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n @param option The option to set\n @param value Pointer to the value to set (type depends on the option),\n or NULL to clear the option\n\n @ingroup terminal"] pub fn ghostty_terminal_set( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, option: GhosttyTerminalOption, value: *const ::std::os::raw::c_void, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Write VT-encoded data to the terminal for processing.\n\n Feeds raw bytes through the terminal's VT stream parser, updating\n terminal state accordingly. By default, sequences that require output\n (queries, device status reports) are silently ignored. Use\n ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install\n a callback that receives response data.\n\n This never fails. Any erroneous input or errors in processing the\n input are logged internally but do not cause this function to fail\n because this input is assumed to be untrusted and from an external\n source; so the primary goal is to keep the terminal state consistent and\n not allow malformed input to corrupt or crash.\n\n @param terminal The terminal handle\n @param data Pointer to the data to write\n @param len Length of the data in bytes\n\n @ingroup terminal"] - pub fn ghostty_terminal_vt_write(terminal: GhosttyTerminal_ptr, data: *const u8, len: usize); + pub fn ghostty_terminal_vt_write(terminal: GhosttyTerminal, data: *const u8, len: usize); } unsafe extern "C" { - #[doc = " Scroll the terminal viewport.\n\n Scrolls the terminal's viewport according to the given behavior.\n When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in\n the value union to specify the number of rows to scroll (negative\n for up, positive for down). For other behaviors, the value is ignored.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n @param behavior The scroll behavior as a tagged union\n\n @ingroup terminal"] + #[doc = " Scroll the terminal viewport.\n\n Scrolls the terminal's viewport according to the given behavior.\n When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in\n the value union to specify the number of rows to scroll (negative\n for up, positive for down). When using GHOSTTY_SCROLL_VIEWPORT_ROW,\n set the row field to the absolute row offset from the top of the\n scrollable area (the same row space as the offset field of\n GhosttyTerminalScrollbar). For other behaviors, the value is ignored.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n @param behavior The scroll behavior as a tagged union\n\n @ingroup terminal"] pub fn ghostty_terminal_scroll_viewport( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, behavior: GhosttyTerminalScrollViewport, ); } unsafe extern "C" { + #[doc = " Return the current compression activity token.\n\n The token is opaque and only equality comparisons are meaningful. An\n embedding application should cache it and restart its compression idle\n delay whenever the value changes. The value may wrap and changes in either\n direction have the same meaning.\n\n This function only observes terminal state. It does not perform or schedule\n compression.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param[out] out_activity Receives the current activity token\n @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument\n is NULL\n\n @ingroup terminal"] + pub fn ghostty_terminal_compression_activity( + terminal: GhosttyTerminal, + out_activity: *mut u64, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Compress eligible terminal scrollback.\n\n Incremental mode performs bounded work suitable for an idle callback. A\n pending result means the application should invoke another step while the\n terminal remains idle. A complete result means no continuation is needed\n until ghostty_terminal_compression_activity() changes. Full mode performs\n one synchronous scan and can stall on large scrollback buffers.\n\n Compression is opportunistic. Complete means the pass has finished, not\n that every page was compressed: pages may be unprofitable or encounter an\n allocation or reclamation failure. Compression changes only the terminal's\n storage representation and never its logical contents or scrollback limit.\n Accessing compressed history restores it transparently.\n\n This function is not thread-safe with other operations on the same\n terminal. The caller must serialize it with writes, rendering, searches,\n and other terminal access.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param mode The amount of compression work to perform\n @param[out] out_result Receives the compression scheduling result\n @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument\n or mode is invalid\n\n @ingroup terminal"] + pub fn ghostty_terminal_compress( + terminal: GhosttyTerminal, + mode: GhosttyTerminalCompressionMode, + out_result: *mut GhosttyTerminalCompressionResult, + ) -> GhosttyResult; +} +unsafe extern "C" { #[doc = " Get the current value of a terminal mode.\n\n Returns the value of the mode identified by the given mode.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param mode The mode identifying the mode to query\n @param[out] out_value On success, set to true if the mode is set, false\n if it is reset\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal\n is NULL or the mode does not correspond to a known mode\n\n @ingroup terminal"] pub fn ghostty_terminal_mode_get( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, mode: GhosttyMode, out_value: *mut bool, ) -> GhosttyResult; @@ -1197,7 +2649,7 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set the value of a terminal mode.\n\n Sets the mode identified by the given mode to the specified value.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param mode The mode identifying the mode to set\n @param value true to set the mode, false to reset it\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal\n is NULL or the mode does not correspond to a known mode\n\n @ingroup terminal"] pub fn ghostty_terminal_mode_set( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, mode: GhosttyMode, value: bool, ) -> GhosttyResult; @@ -1205,27 +2657,46 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Get data from a terminal instance.\n\n Extracts typed data from the given terminal based on the specified\n data type. The output pointer must be of the appropriate type for the\n requested data kind. Valid data types and output types are documented\n in the `GhosttyTerminalData` enum.\n\n @param terminal The terminal handle (may be NULL)\n @param data The type of data to extract\n @param out Pointer to store the extracted data (type depends on data parameter)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal\n is NULL or the data type is invalid\n\n @ingroup terminal"] pub fn ghostty_terminal_get( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, data: GhosttyTerminalData, out: *mut ::std::os::raw::c_void, ) -> GhosttyResult; } unsafe extern "C" { + #[doc = " Get multiple data fields from a terminal in a single call.\n\n This is an optimization over calling ghostty_terminal_get()\n repeatedly, particularly useful in environments with high per-call\n overhead such as FFI or Cgo.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n The type of each values[i] pointer must match the output type\n documented for keys[i].\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param terminal The terminal handle (may be NULL)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup terminal"] + pub fn ghostty_terminal_get_multi( + terminal: GhosttyTerminal, + count: usize, + keys: *const GhosttyTerminalData, + values: *mut *mut ::std::os::raw::c_void, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { #[doc = " Resolve a point in the terminal grid to a grid reference.\n\n Resolves the given point (which can be in active, viewport, screen,\n or history coordinates) to a grid reference for that location. Use\n ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell\n and row.\n\n Lookups using the `active` and `viewport` tags are fast. The `screen`\n and `history` tags may require traversing the full scrollback page list\n to resolve the y coordinate, so they can be expensive for large\n scrollback buffers.\n\n This function isn't meant to be used as the core of render loop. It\n isn't built to sustain the framerates needed for rendering large screens.\n Use the render state API for that. This API is instead meant for less\n strictly performance-sensitive use cases.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param point The point specifying which cell to look up\n @param[out] out_ref On success, set to the grid reference at the given point (may be NULL)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal\n is NULL or the point is out of bounds\n\n @ingroup terminal"] pub fn ghostty_terminal_grid_ref( - terminal: GhosttyTerminal_ptr, + terminal: GhosttyTerminal, point: GhosttyPoint, out_ref: *mut GhosttyGridRef, ) -> GhosttyResult; } -#[doc = " Plain text (no escape sequences)."] -pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN: GhosttyFormatterFormat = 0; -#[doc = " VT sequences preserving colors, styles, URLs, etc."] -pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_VT: GhosttyFormatterFormat = 1; -#[doc = " HTML with inline styles."] -pub const GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_HTML: GhosttyFormatterFormat = 2; -#[doc = " Output format.\n\n @ingroup formatter"] -pub type GhosttyFormatterFormat = ::std::os::raw::c_uint; +unsafe extern "C" { + #[doc = " Create an owned tracked grid reference for a terminal point.\n\n This is the tracked variant of ghostty_terminal_grid_ref(). The returned\n handle follows the referenced cell as the terminal's page list is modified:\n scrolling, pruning, resize/reflow, and other page-list operations update the\n tracked reference automatically.\n\n The reference is attached to the terminal screen/page-list that is active at\n creation time.\n\n If the point is outside the requested coordinate space, this returns\n GHOSTTY_INVALID_VALUE and writes NULL to out_ref.\n\n The returned handle must be freed with ghostty_tracked_grid_ref_free(). If\n the terminal is freed first, the handle remains valid only for\n tracked-grid-ref APIs: it reports no value and can still be freed.\n\n @param terminal Terminal instance.\n @param point Point to track.\n @param[out] out_ref On success, receives the tracked reference handle.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal,\n point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation\n fails.\n\n @ingroup terminal"] + pub fn ghostty_terminal_grid_ref_track( + terminal: GhosttyTerminal, + point: GhosttyPoint, + out_ref: *mut GhosttyTrackedGridRef, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Convert a grid reference back to a point in the given coordinate system.\n\n This is the inverse of ghostty_terminal_grid_ref(): given a grid reference,\n it returns the x/y coordinates in the requested coordinate system (active,\n viewport, screen, or history).\n\n The grid reference must have been obtained from the same terminal instance.\n Like all grid references, it is only valid until the next mutating terminal\n call.\n\n Not every grid reference is representable in every coordinate system. For\n example, a cell in scrollback history cannot be expressed in active\n coordinates, and a cell that has scrolled off the visible area cannot be\n expressed in viewport coordinates. In these cases, the function returns\n GHOSTTY_NO_VALUE.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param ref Pointer to the grid reference to convert\n @param tag The target coordinate system\n @param[out] out On success, set to the coordinate in the requested system (may be NULL)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal\n or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside\n the requested coordinate system\n\n @ingroup terminal"] + pub fn ghostty_terminal_point_from_grid_ref( + terminal: GhosttyTerminal, + ref_: *const GhosttyGridRef, + tag: GhosttyPointTag, + out: *mut GhosttyPointCoordinate, + ) -> GhosttyResult; +} #[doc = " Extra screen state to include in styled output.\n\n @ingroup formatter"] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] @@ -1310,13 +2781,6 @@ const _: () = { ["Offset of field: GhosttyFormatterTerminalExtra::screen"] [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, screen) - 16usize]; }; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyFormatter { - _unused: [u8; 0], -} -#[doc = " Opaque handle to a formatter instance.\n\n @ingroup formatter"] -pub type GhosttyFormatter_ptr = *mut GhosttyFormatter; #[doc = " Options for creating a terminal formatter.\n\n @ingroup formatter"] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -1331,7 +2795,7 @@ pub struct GhosttyFormatterTerminalOptions { pub trim: bool, #[doc = " Extra terminal state to include in styled output."] pub extra: GhosttyFormatterTerminalExtra, - #[doc = " Optional selection to restrict output to a range."] + #[doc = " Optional selection to restrict output to a range.\n If NULL, the entire screen is formatted."] pub selection: *const GhosttySelection, } #[allow(clippy::unnecessary_operation, clippy::identity_op)] @@ -1366,15 +2830,15 @@ unsafe extern "C" { #[doc = " Create a formatter for a terminal's active screen.\n\n The terminal must outlive the formatter. The formatter stores a borrowed\n reference to the terminal and reads its current state on each format call.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param formatter Pointer to store the created formatter handle\n @param terminal The terminal to format (must not be NULL)\n @param options Formatting options\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup formatter"] pub fn ghostty_formatter_terminal_new( allocator: *const GhosttyAllocator, - formatter: *mut GhosttyFormatter_ptr, - terminal: GhosttyTerminal_ptr, + formatter: *mut GhosttyFormatter, + terminal: GhosttyTerminal, options: GhosttyFormatterTerminalOptions, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Run the formatter and produce output into the caller-provided buffer.\n\n Each call formats the current terminal state. Pass NULL for buf to\n query the required buffer size without writing any output; in that case\n out_written receives the required size and the return value is\n GHOSTTY_OUT_OF_SPACE.\n\n If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets\n out_written to the required size. The caller can then retry with a\n larger buffer.\n\n @param formatter The formatter handle (must not be NULL)\n @param buf Pointer to the output buffer, or NULL to query size\n @param buf_len Length of the output buffer in bytes\n @param out_written Pointer to receive the number of bytes written,\n or the required size on failure\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup formatter"] pub fn ghostty_formatter_format_buf( - formatter: GhosttyFormatter_ptr, + formatter: GhosttyFormatter, buf: *mut u8, buf_len: usize, out_written: *mut usize, @@ -1383,7 +2847,7 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Run the formatter and return an allocated buffer with the output.\n\n Each call formats the current terminal state. The buffer is allocated\n using the provided allocator (or the default allocator if NULL).\n The caller is responsible for freeing the returned buffer with\n ghostty_free(), passing the same allocator (or NULL for the default)\n that was used for the allocation.\n\n @param formatter The formatter handle (must not be NULL)\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param out_ptr Pointer to receive the allocated buffer\n @param out_len Pointer to receive the length of the output in bytes\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation\n failure\n\n @ingroup formatter"] pub fn ghostty_formatter_format_alloc( - formatter: GhosttyFormatter_ptr, + formatter: GhosttyFormatter, allocator: *const GhosttyAllocator, out_ptr: *mut *mut u8, out_len: *mut usize, @@ -1391,35 +2855,17 @@ unsafe extern "C" { } unsafe extern "C" { #[doc = " Free a formatter instance.\n\n Releases all resources associated with the formatter. After this call,\n the formatter handle becomes invalid.\n\n @param formatter The formatter handle to free (may be NULL)\n\n @ingroup formatter"] - pub fn ghostty_formatter_free(formatter: GhosttyFormatter_ptr); -} -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyRenderState { - _unused: [u8; 0], -} -#[doc = " Opaque handle to a render state instance.\n\n @ingroup render"] -pub type GhosttyRenderState_ptr = *mut GhosttyRenderState; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyRenderStateRowIterator { - _unused: [u8; 0], + pub fn ghostty_formatter_free(formatter: GhosttyFormatter); } -#[doc = " Opaque handle to a render-state row iterator.\n\n @ingroup render"] -pub type GhosttyRenderStateRowIterator_ptr = *mut GhosttyRenderStateRowIterator; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyRenderStateRowCells { - _unused: [u8; 0], -} -#[doc = " Opaque handle to render-state row cells.\n\n @ingroup render"] -pub type GhosttyRenderStateRowCells_ptr = *mut GhosttyRenderStateRowCells; #[doc = " Not dirty at all; rendering can be skipped."] pub const GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FALSE: GhosttyRenderStateDirty = 0; #[doc = " Some rows changed; renderer can redraw incrementally."] pub const GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_PARTIAL: GhosttyRenderStateDirty = 1; #[doc = " Global state changed; renderer should redraw everything."] pub const GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_FULL: GhosttyRenderStateDirty = 2; +#[doc = " Global state changed; renderer should redraw everything."] +pub const GhosttyRenderStateDirty_GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE: GhosttyRenderStateDirty = + 2147483647; #[doc = " Dirty state of a render state after update.\n\n @ingroup render"] pub type GhosttyRenderStateDirty = ::std::os::raw::c_uint; #[doc = " Bar cursor (DECSCUSR 5, 6)."] @@ -1433,6 +2879,9 @@ pub const GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL GhosttyRenderStateCursorVisualStyle = 2; #[doc = " Hollow block cursor."] pub const GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW : GhosttyRenderStateCursorVisualStyle = 3 ; +#[doc = " Hollow block cursor."] +pub const GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE: + GhosttyRenderStateCursorVisualStyle = 2147483647; #[doc = " Visual style of the cursor.\n\n @ingroup render"] pub type GhosttyRenderStateCursorVisualStyle = ::std::os::raw::c_uint; #[doc = " Invalid / sentinel value."] @@ -1483,10 +2932,16 @@ pub const GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y: #[doc = " Whether the cursor is on the tail of a wide character (bool).\n Only valid when CURSOR_VIEWPORT_HAS_VALUE is true."] pub const GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL: GhosttyRenderStateData = 17; +#[doc = " Whether the cursor is on the tail of a wide character (bool).\n Only valid when CURSOR_VIEWPORT_HAS_VALUE is true."] +pub const GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_MAX_VALUE: GhosttyRenderStateData = + 2147483647; #[doc = " Queryable data kinds for ghostty_render_state_get().\n\n @ingroup render"] pub type GhosttyRenderStateData = ::std::os::raw::c_uint; #[doc = " Set dirty state (GhosttyRenderStateDirty)."] pub const GhosttyRenderStateOption_GHOSTTY_RENDER_STATE_OPTION_DIRTY: GhosttyRenderStateOption = 0; +#[doc = " Set dirty state (GhosttyRenderStateDirty)."] +pub const GhosttyRenderStateOption_GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE: GhosttyRenderStateOption = + 2147483647; #[doc = " Settable options for ghostty_render_state_set().\n\n @ingroup render"] pub type GhosttyRenderStateOption = ::std::os::raw::c_uint; #[doc = " Invalid / sentinel value."] @@ -1504,18 +2959,24 @@ pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_CELLS: Ghostty #[doc = " Row-local selected cell range (GhosttyRenderStateRowSelection)."] pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION: GhosttyRenderStateRowData = 4; +#[doc = " Row-local selected cell range (GhosttyRenderStateRowSelection)."] +pub const GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE: + GhosttyRenderStateRowData = 2147483647; #[doc = " Queryable data kinds for ghostty_render_state_row_get().\n\n @ingroup render"] pub type GhosttyRenderStateRowData = ::std::os::raw::c_uint; #[doc = " Set dirty state for the current row (bool)."] pub const GhosttyRenderStateRowOption_GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY: GhosttyRenderStateRowOption = 0; +#[doc = " Set dirty state for the current row (bool)."] +pub const GhosttyRenderStateRowOption_GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE: + GhosttyRenderStateRowOption = 2147483647; #[doc = " Settable options for ghostty_render_state_row_set().\n\n @ingroup render"] pub type GhosttyRenderStateRowOption = ::std::os::raw::c_uint; -#[doc = " Row-local selection range."] +#[doc = " Row-local selection range.\n\n This struct uses the sized-struct ABI pattern. Initialize with\n GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying\n GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.\n\n Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE\n if the current row does not intersect the current selection.\n\n @ingroup render"] #[repr(C)] #[derive(Debug, Default, Copy, Clone)] pub struct GhosttyRenderStateRowSelection { - #[doc = " Size of this struct in bytes."] + #[doc = " Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection)."] pub size: usize, #[doc = " Start column of the row-local selection range, inclusive."] pub start_x: u16, @@ -1584,32 +3045,43 @@ unsafe extern "C" { #[doc = " Create a new render state instance.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param state Pointer to store the created render state handle\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation\n failure\n\n @ingroup render"] pub fn ghostty_render_state_new( allocator: *const GhosttyAllocator, - state: *mut GhosttyRenderState_ptr, + state: *mut GhosttyRenderState, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a render state instance.\n\n Releases all resources associated with the render state. After this call,\n the render state handle becomes invalid.\n\n @param state The render state handle to free (may be NULL)\n\n @ingroup render"] - pub fn ghostty_render_state_free(state: GhosttyRenderState_ptr); + pub fn ghostty_render_state_free(state: GhosttyRenderState); } unsafe extern "C" { - #[doc = " Update a render state instance from a terminal.\n\n This consumes terminal/screen dirty state in the same way as the internal\n render state update path.\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires\n allocation and that allocation fails\n\n @ingroup render"] + #[doc = " Update a render state instance from a terminal.\n\n This consumes terminal/screen dirty state in the same way as the internal\n render state update path.\n\n This is a convenience function that performs a full update in one call,\n equivalent to ghostty_render_state_begin_update immediately followed by\n ghostty_render_state_end_update. Callers that hold a lock over the\n terminal state should prefer calling the two phases directly so that the\n lock is only held for the begin phase.\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires\n allocation and that allocation fails\n\n @ingroup render"] pub fn ghostty_render_state_update( - state: GhosttyRenderState_ptr, - terminal: GhosttyTerminal_ptr, + state: GhosttyRenderState, + terminal: GhosttyTerminal, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Begin an update of a render state instance from a terminal.\n\n Every begin must be completed with a ghostty_render_state_end_update call\n before the render state is read.\n\n This two-phase structure exists for callers that synchronize access to the\n terminal state (e.g. with a lock shared with an IO thread): only this\n function requires terminal access, so a caller can hold its lock for this\n call only and then call ghostty_render_state_end_update after releasing\n it. The end phase exclusively reads and writes memory owned by the render\n state, so it is safe to call while the terminal is being modified.\n\n Work that doesn't require terminal access may be deferred to the end phase\n to keep this call (and therefore lock hold time) as short as possible.\n Callers must treat the render state as incomplete until\n ghostty_render_state_end_update is called.\n\n This consumes terminal/screen dirty state in the same way as the internal\n render state update path.\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires\n allocation and that allocation fails\n\n @ingroup render"] + pub fn ghostty_render_state_begin_update( + state: GhosttyRenderState, + terminal: GhosttyTerminal, ) -> GhosttyResult; } unsafe extern "C" { + #[doc = " Complete a prior ghostty_render_state_begin_update call by performing any\n deferred work.\n\n This only reads and writes memory owned by the render state, so it is safe\n to call while the terminal is being modified (no terminal synchronization\n is required). Calling this without a prior begin is a safe no-op.\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is\n NULL\n\n @ingroup render"] + pub fn ghostty_render_state_end_update(state: GhosttyRenderState) -> GhosttyResult; +} +unsafe extern "C" { #[doc = " Get a value from a render state.\n\n The `out` pointer must point to a value of the type corresponding to the\n requested data kind (see GhosttyRenderStateData).\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param data The data kind to query\n @param[out] out Pointer to receive the queried value\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is\n NULL or `data` is not a recognized enum value\n\n @ingroup render"] pub fn ghostty_render_state_get( - state: GhosttyRenderState_ptr, + state: GhosttyRenderState, data: GhosttyRenderStateData, out: *mut ::std::os::raw::c_void, ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get multiple data fields from a render state in a single call."] + #[doc = " Get multiple data fields from a render state in a single call.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup render"] pub fn ghostty_render_state_get_multi( - state: GhosttyRenderState_ptr, + state: GhosttyRenderState, count: usize, keys: *const GhosttyRenderStateData, values: *mut *mut ::std::os::raw::c_void, @@ -1619,7 +3091,7 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set an option on a render state.\n\n The `value` pointer must point to a value of the type corresponding to the\n requested option kind (see GhosttyRenderStateOption).\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param option The option to set\n @param[in] value Pointer to the value to set (NULL returns\n GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `value` is NULL\n\n @ingroup render"] pub fn ghostty_render_state_set( - state: GhosttyRenderState_ptr, + state: GhosttyRenderState, option: GhosttyRenderStateOption, value: *const ::std::os::raw::c_void, ) -> GhosttyResult; @@ -1627,7 +3099,7 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Get the current color information from a render state.\n\n This writes as many fields as fit in the caller-provided sized struct.\n `out_colors->size` must be set by the caller (typically via\n GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)).\n\n @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param[out] out_colors Sized output struct to receive render-state colors\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or\n `out_colors` is NULL, or if `out_colors->size` is smaller than\n `sizeof(size_t)`\n\n @ingroup render"] pub fn ghostty_render_state_colors_get( - state: GhosttyRenderState_ptr, + state: GhosttyRenderState, out_colors: *mut GhosttyRenderStateColors, ) -> GhosttyResult; } @@ -1635,31 +3107,29 @@ unsafe extern "C" { #[doc = " Create a new row iterator instance.\n\n All fields except the allocator are left undefined until populated\n via ghostty_render_state_get() with\n GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param[out] out_iterator On success, receives the created iterator handle\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation\n failure\n\n @ingroup render"] pub fn ghostty_render_state_row_iterator_new( allocator: *const GhosttyAllocator, - out_iterator: *mut GhosttyRenderStateRowIterator_ptr, + out_iterator: *mut GhosttyRenderStateRowIterator, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a render-state row iterator.\n\n @param iterator The iterator handle to free (may be NULL)\n\n @ingroup render"] - pub fn ghostty_render_state_row_iterator_free(iterator: GhosttyRenderStateRowIterator_ptr); + pub fn ghostty_render_state_row_iterator_free(iterator: GhosttyRenderStateRowIterator); } unsafe extern "C" { #[doc = " Move a render-state row iterator to the next row.\n\n Returns true if the iterator moved successfully and row data is\n available to read at the new position.\n\n @param iterator The iterator handle to advance (may be NULL)\n @return true if advanced to the next row, false if `iterator` is\n NULL or if the iterator has reached the end\n\n @ingroup render"] - pub fn ghostty_render_state_row_iterator_next( - iterator: GhosttyRenderStateRowIterator_ptr, - ) -> bool; + pub fn ghostty_render_state_row_iterator_next(iterator: GhosttyRenderStateRowIterator) -> bool; } unsafe extern "C" { #[doc = " Get a value from the current row in a render-state row iterator.\n\n The `out` pointer must point to a value of the type corresponding to the\n requested data kind (see GhosttyRenderStateRowData).\n Call ghostty_render_state_row_iterator_next() at least once before\n calling this function.\n\n @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE)\n @param data The data kind to query\n @param[out] out Pointer to receive the queried value\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if\n `iterator` is NULL or the iterator is not positioned on a row\n\n @ingroup render"] pub fn ghostty_render_state_row_get( - iterator: GhosttyRenderStateRowIterator_ptr, + iterator: GhosttyRenderStateRowIterator, data: GhosttyRenderStateRowData, out: *mut ::std::os::raw::c_void, ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get multiple data fields from the current row in a single call."] + #[doc = " Get multiple data fields from the current row in a single call.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup render"] pub fn ghostty_render_state_row_get_multi( - iterator: GhosttyRenderStateRowIterator_ptr, + iterator: GhosttyRenderStateRowIterator, count: usize, keys: *const GhosttyRenderStateRowData, values: *mut *mut ::std::os::raw::c_void, @@ -1669,7 +3139,7 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set an option on the current row in a render-state row iterator.\n\n The `value` pointer must point to a value of the type corresponding to the\n requested option kind (see GhosttyRenderStateRowOption).\n Call ghostty_render_state_row_iterator_next() at least once before\n calling this function.\n\n @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE)\n @param option The option to set\n @param[in] value Pointer to the value to set (NULL returns\n GHOSTTY_INVALID_VALUE)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if\n `iterator` is NULL or the iterator is not positioned on a row\n\n @ingroup render"] pub fn ghostty_render_state_row_set( - iterator: GhosttyRenderStateRowIterator_ptr, + iterator: GhosttyRenderStateRowIterator, option: GhosttyRenderStateRowOption, value: *const ::std::os::raw::c_void, ) -> GhosttyResult; @@ -1678,7 +3148,7 @@ unsafe extern "C" { #[doc = " Create a new row cells instance.\n\n All fields except the allocator are left undefined until populated\n via ghostty_render_state_row_get() with\n GHOSTTY_RENDER_STATE_ROW_DATA_CELLS.\n\n You can reuse this value repeatedly with ghostty_render_state_row_get() to\n avoid allocating a new cells container for every row.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param[out] out_cells On success, receives the created row cells handle\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation\n failure\n\n @ingroup render"] pub fn ghostty_render_state_row_cells_new( allocator: *const GhosttyAllocator, - out_cells: *mut GhosttyRenderStateRowCells_ptr, + out_cells: *mut GhosttyRenderStateRowCells, ) -> GhosttyResult; } #[doc = " Invalid / sentinel value."] @@ -1702,40 +3172,43 @@ pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_ #[doc = " The resolved foreground color of the cell (GhosttyColorRgb).\n Resolves palette indices through the palette. Bold color handling\n is not applied; the caller should handle bold styling separately.\n Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground\n color, in which case the caller should use whatever default foreground\n color it wants (e.g. the terminal foreground)."] pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR: GhosttyRenderStateRowCellsData = 6; -#[doc = " Whether the cell is contained within the current selection (bool)."] +#[doc = " Whether the cell is contained within the current selection (bool).\n This returns true when the cell's column is within the current row's\n row-local selection range, and false otherwise. Rendering policy for\n selected cells (colors, inversion, etc.) is left to the caller.\n\n Renderers that can draw cells in spans may be more efficient querying\n GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that\n range directly, avoiding one C API call per cell for selection state."] pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED: GhosttyRenderStateRowCellsData = 7; -#[doc = " Whether the cell has any explicit styling (bool)."] +#[doc = " Whether the cell has any explicit styling (bool).\n This is equivalent to querying the raw cell's\n GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw\n GhosttyCell for renderers that only need to know whether fetching the\n full style is necessary."] pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING: GhosttyRenderStateRowCellsData = 8; -#[doc = " Encode the current cell's full grapheme cluster as UTF-8 into a GhosttyBuffer."] +#[doc = " Encode the current cell's full grapheme cluster as UTF-8 into a\n caller-provided buffer (GhosttyBuffer).\n\n The base codepoint is encoded first, followed by any extra grapheme\n codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text.\n\n If ptr is NULL or cap is too small for a non-empty cell, returns\n GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required\n buffer size in bytes."] pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8: GhosttyRenderStateRowCellsData = 9; +#[doc = " Encode the current cell's full grapheme cluster as UTF-8 into a\n caller-provided buffer (GhosttyBuffer).\n\n The base codepoint is encoded first, followed by any extra grapheme\n codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text.\n\n If ptr is NULL or cap is too small for a non-empty cell, returns\n GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required\n buffer size in bytes."] +pub const GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE: + GhosttyRenderStateRowCellsData = 2147483647; #[doc = " Queryable data kinds for ghostty_render_state_row_cells_get().\n\n @ingroup render"] pub type GhosttyRenderStateRowCellsData = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Move a render-state row cells iterator to the next cell.\n\n Returns true if the iterator moved successfully and cell data is\n available to read at the new position.\n\n @param cells The row cells handle to advance (may be NULL)\n @return true if advanced to the next cell, false if `cells` is\n NULL or if the iterator has reached the end\n\n @ingroup render"] - pub fn ghostty_render_state_row_cells_next(cells: GhosttyRenderStateRowCells_ptr) -> bool; + pub fn ghostty_render_state_row_cells_next(cells: GhosttyRenderStateRowCells) -> bool; } unsafe extern "C" { #[doc = " Move a render-state row cells iterator to a specific column.\n\n Positions the iterator at the given x (column) index so that\n subsequent reads return data for that cell.\n\n @param cells The row cells handle to reposition (NULL returns\n GHOSTTY_INVALID_VALUE)\n @param x The zero-based column index to select\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `cells`\n is NULL or `x` is out of range\n\n @ingroup render"] pub fn ghostty_render_state_row_cells_select( - cells: GhosttyRenderStateRowCells_ptr, + cells: GhosttyRenderStateRowCells, x: u16, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Get a value from the current cell in a render-state row cells iterator.\n\n The `out` pointer must point to a value of the type corresponding to the\n requested data kind (see GhosttyRenderStateRowCellsData).\n Call ghostty_render_state_row_cells_next() or\n ghostty_render_state_row_cells_select() at least once before\n calling this function.\n\n @param cells The row cells handle to query (NULL returns GHOSTTY_INVALID_VALUE)\n @param data The data kind to query\n @param[out] out Pointer to receive the queried value\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if\n `cells` is NULL or the iterator is not positioned on a cell\n\n @ingroup render"] pub fn ghostty_render_state_row_cells_get( - cells: GhosttyRenderStateRowCells_ptr, + cells: GhosttyRenderStateRowCells, data: GhosttyRenderStateRowCellsData, out: *mut ::std::os::raw::c_void, ) -> GhosttyResult; } unsafe extern "C" { - #[doc = " Get multiple data fields from the current cell in a single call."] + #[doc = " Get multiple data fields from the current cell in a single call.\n\n Each element in the keys array specifies a data kind, and the\n corresponding element in the values array receives the result.\n\n Processing stops at the first error; on success out_written\n is set to count, on error it is set to the index of the\n failing key (i.e. the number of values successfully written).\n\n @param cells The row cells handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param count Number of key/value pairs\n @param keys Array of data kinds to query\n @param values Array of output pointers (types must match each key's\n documented output type)\n @param[out] out_written On return, receives the number of values\n successfully written (may be NULL)\n @return GHOSTTY_SUCCESS if all queries succeed\n\n @ingroup render"] pub fn ghostty_render_state_row_cells_get_multi( - cells: GhosttyRenderStateRowCells_ptr, + cells: GhosttyRenderStateRowCells, count: usize, keys: *const GhosttyRenderStateRowCellsData, values: *mut *mut ::std::os::raw::c_void, @@ -1744,22 +3217,39 @@ unsafe extern "C" { } unsafe extern "C" { #[doc = " Free a row cells instance.\n\n @param cells The row cells handle to free (may be NULL)\n\n @ingroup render"] - pub fn ghostty_render_state_row_cells_free(cells: GhosttyRenderStateRowCells_ptr); + pub fn ghostty_render_state_row_cells_free(cells: GhosttyRenderStateRowCells); } -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyOscParser { - _unused: [u8; 0], +unsafe extern "C" { + #[doc = " Free a tracked grid reference.\n\n Passing NULL is allowed and has no effect. A tracked reference may be freed\n after the terminal that created it is freed.\n\n @param ref Tracked grid reference to free.\n\n @ingroup grid_ref"] + pub fn ghostty_tracked_grid_ref_free(ref_: GhosttyTrackedGridRef); } -#[doc = " Opaque handle to an OSC parser instance.\n\n This handle represents an OSC (Operating System Command) parser that can\n be used to parse the contents of OSC sequences.\n\n @ingroup osc"] -pub type GhosttyOscParser_ptr = *mut GhosttyOscParser; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyOscCommand { - _unused: [u8; 0], +unsafe extern "C" { + #[doc = " Return whether a tracked grid reference currently has a meaningful value.\n\n If the terminal that created the tracked reference has been freed, this\n returns false.\n\n @param ref Tracked grid reference.\n @return true if the reference currently has a meaningful value.\n\n @ingroup grid_ref"] + pub fn ghostty_tracked_grid_ref_has_value(ref_: GhosttyTrackedGridRef) -> bool; +} +unsafe extern "C" { + #[doc = " Convert a tracked grid reference to a point in the requested coordinate\n space.\n\n This is the tracked equivalent of ghostty_terminal_point_from_grid_ref().\n Unlike snapshotting, this does not expose an intermediate untracked\n GhosttyGridRef.\n\n A tracked reference is resolved against the terminal screen/page-list that\n currently owns the reference. If the terminal has switched between primary\n and alternate screens since the reference was created or last set, this may\n be different from the terminal's currently active screen.\n\n If the tracked reference no longer has a meaningful value, this returns\n GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot\n be represented in the requested coordinate space, including after the\n terminal that created the tracked reference has been freed.\n\n @param ref Tracked grid reference.\n @param tag Coordinate space to convert into.\n @param[out] out_point On success, receives the coordinate. May be NULL.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,\n or GHOSTTY_NO_VALUE if there is no representable value.\n\n @ingroup grid_ref"] + pub fn ghostty_tracked_grid_ref_point( + ref_: GhosttyTrackedGridRef, + tag: GhosttyPointTag, + out_point: *mut GhosttyPointCoordinate, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Move an existing tracked grid reference to a new terminal point.\n\n On success, the tracked reference begins tracking the new point and any prior\n \"no value\" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked\n reference is left unchanged.\n\n The terminal must be the same terminal that created the tracked reference.\n The point is resolved against the terminal screen/page-list that is active at\n the time this function is called. If the terminal has switched between\n primary and alternate screens, this may move the tracked reference from one\n screen/page-list to the other.\n\n @param ref Tracked grid reference.\n @param terminal Terminal instance that owns the reference.\n @param point New point to track.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal,\n or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails.\n\n @ingroup grid_ref"] + pub fn ghostty_tracked_grid_ref_set( + ref_: GhosttyTrackedGridRef, + terminal: GhosttyTerminal, + point: GhosttyPoint, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Snapshot a tracked grid reference into a regular GhosttyGridRef.\n\n The returned GhosttyGridRef is an untracked snapshot and has the same\n lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the\n next terminal update. Snapshot immediately before calling\n ghostty_grid_ref_cell(), ghostty_grid_ref_row(),\n ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or\n ghostty_grid_ref_style().\n\n If the tracked reference no longer has a meaningful value, this returns\n GHOSTTY_NO_VALUE. This includes references whose owning terminal has been\n freed.\n\n @param ref Tracked grid reference.\n @param[out] out_ref On success, receives an untracked snapshot. May be NULL.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,\n or GHOSTTY_NO_VALUE if the tracked location was discarded.\n\n @ingroup grid_ref"] + pub fn ghostty_tracked_grid_ref_snapshot( + ref_: GhosttyTrackedGridRef, + out_ref: *mut GhosttyGridRef, + ) -> GhosttyResult; } -#[doc = " Opaque handle to a single OSC command.\n\n This handle represents a parsed OSC (Operating System Command) command.\n The command can be queried for its type and associated data.\n\n @ingroup osc"] -pub type GhosttyOscCommand_ptr = *mut GhosttyOscCommand; pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_INVALID: GhosttyOscCommandType = 0; pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE: GhosttyOscCommandType = 1; pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON: GhosttyOscCommandType = 2; @@ -1789,56 +3279,53 @@ pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION: Ghos 20; pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_CONEMU_COMMENT: GhosttyOscCommandType = 21; pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING: GhosttyOscCommandType = 22; +pub const GhosttyOscCommandType_GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE: GhosttyOscCommandType = + 2147483647; #[doc = " OSC command types.\n\n @ingroup osc"] pub type GhosttyOscCommandType = ::std::os::raw::c_uint; #[doc = " Invalid data type. Never results in any data extraction."] pub const GhosttyOscCommandData_GHOSTTY_OSC_DATA_INVALID: GhosttyOscCommandData = 0; #[doc = " Window title string data.\n\n Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE\n\n Output type: const char ** (pointer to null-terminated string)\n\n Lifetime: Valid until the next call to any ghostty_osc_* function with\n the same parser instance. Memory is owned by the parser."] pub const GhosttyOscCommandData_GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR: GhosttyOscCommandData = 1; +#[doc = " Window title string data.\n\n Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE\n\n Output type: const char ** (pointer to null-terminated string)\n\n Lifetime: Valid until the next call to any ghostty_osc_* function with\n the same parser instance. Memory is owned by the parser."] +pub const GhosttyOscCommandData_GHOSTTY_OSC_DATA_MAX_VALUE: GhosttyOscCommandData = 2147483647; #[doc = " OSC command data types.\n\n These values specify what type of data to extract from an OSC command\n using `ghostty_osc_command_data`.\n\n @ingroup osc"] pub type GhosttyOscCommandData = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Create a new OSC parser instance.\n\n Creates a new OSC (Operating System Command) parser using the provided\n allocator. The parser must be freed using ghostty_vt_osc_free() when\n no longer needed.\n\n @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n @param parser Pointer to store the created parser handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup osc"] pub fn ghostty_osc_new( allocator: *const GhosttyAllocator, - parser: *mut GhosttyOscParser_ptr, + parser: *mut GhosttyOscParser, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free an OSC parser instance.\n\n Releases all resources associated with the OSC parser. After this call,\n the parser handle becomes invalid and must not be used.\n\n @param parser The parser handle to free (may be NULL)\n\n @ingroup osc"] - pub fn ghostty_osc_free(parser: GhosttyOscParser_ptr); + pub fn ghostty_osc_free(parser: GhosttyOscParser); } unsafe extern "C" { #[doc = " Reset an OSC parser instance to its initial state.\n\n Resets the parser state, clearing any partially parsed OSC sequences\n and returning the parser to its initial state. This is useful for\n reusing a parser instance or recovering from parse errors.\n\n @param parser The parser handle to reset, must not be null.\n\n @ingroup osc"] - pub fn ghostty_osc_reset(parser: GhosttyOscParser_ptr); + pub fn ghostty_osc_reset(parser: GhosttyOscParser); } unsafe extern "C" { #[doc = " Parse the next byte in an OSC sequence.\n\n Processes a single byte as part of an OSC sequence. The parser maintains\n internal state to track the progress through the sequence. Call this\n function for each byte in the sequence data.\n\n When finished pumping the parser with bytes, call ghostty_osc_end\n to get the final result.\n\n @param parser The parser handle, must not be null.\n @param byte The next byte to parse\n\n @ingroup osc"] - pub fn ghostty_osc_next(parser: GhosttyOscParser_ptr, byte: u8); + pub fn ghostty_osc_next(parser: GhosttyOscParser, byte: u8); } unsafe extern "C" { #[doc = " Finalize OSC parsing and retrieve the parsed command.\n\n Call this function after feeding all bytes of an OSC sequence to the parser\n using ghostty_osc_next() with the exception of the terminating character\n (ESC or ST). This function finalizes the parsing process and returns the\n parsed OSC command.\n\n The return value is never NULL. Invalid commands will return a command\n with type GHOSTTY_OSC_COMMAND_INVALID.\n\n The terminator parameter specifies the byte that terminated the OSC sequence\n (typically 0x07 for BEL or 0x5C for ST after ESC). This information is\n preserved in the parsed command so that responses can use the same terminator\n format for better compatibility with the calling program. For commands that\n do not require a response, this parameter is ignored and the resulting\n command will not retain the terminator information.\n\n The returned command handle is valid until the next call to any\n `ghostty_osc_*` function with the same parser instance with the exception\n of command introspection functions such as `ghostty_osc_command_type`.\n\n @param parser The parser handle, must not be null.\n @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)\n @return Handle to the parsed OSC command\n\n @ingroup osc"] - pub fn ghostty_osc_end(parser: GhosttyOscParser_ptr, terminator: u8) -> GhosttyOscCommand_ptr; + pub fn ghostty_osc_end(parser: GhosttyOscParser, terminator: u8) -> GhosttyOscCommand; } unsafe extern "C" { #[doc = " Get the type of an OSC command.\n\n Returns the type identifier for the given OSC command. This can be used\n to determine what kind of command was parsed and what data might be\n available from it.\n\n @param command The OSC command handle to query (may be NULL)\n @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL\n\n @ingroup osc"] - pub fn ghostty_osc_command_type(command: GhosttyOscCommand_ptr) -> GhosttyOscCommandType; + pub fn ghostty_osc_command_type(command: GhosttyOscCommand) -> GhosttyOscCommandType; } unsafe extern "C" { #[doc = " Extract data from an OSC command.\n\n Extracts typed data from the given OSC command based on the specified\n data type. The output pointer must be of the appropriate type for the\n requested data kind. Valid command types, output types, and memory\n safety information are documented in the `GhosttyOscCommandData` enum.\n\n @param command The OSC command handle to query (may be NULL)\n @param data The type of data to extract\n @param out Pointer to store the extracted data (type depends on data parameter)\n @return true if data extraction was successful, false otherwise\n\n @ingroup osc"] pub fn ghostty_osc_command_data( - command: GhosttyOscCommand_ptr, + command: GhosttyOscCommand, data: GhosttyOscCommandData, out: *mut ::std::os::raw::c_void, ) -> bool; } -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttySgrParser { - _unused: [u8; 0], -} -#[doc = " Opaque handle to an SGR parser instance.\n\n This handle represents an SGR (Select Graphic Rendition) parser that can\n be used to parse SGR sequences and extract individual text attributes.\n\n @ingroup sgr"] -pub type GhosttySgrParser_ptr = *mut GhosttySgrParser; pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_UNSET: GhosttySgrAttributeTag = 0; pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_UNKNOWN: GhosttySgrAttributeTag = 1; pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_BOLD: GhosttySgrAttributeTag = 2; @@ -1871,6 +3358,7 @@ pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_BRIGHT_BG_8: GhosttySgrAttribu pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_BRIGHT_FG_8: GhosttySgrAttributeTag = 28; pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_BG_256: GhosttySgrAttributeTag = 29; pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_FG_256: GhosttySgrAttributeTag = 30; +pub const GhosttySgrAttributeTag_GHOSTTY_SGR_ATTR_MAX_VALUE: GhosttySgrAttributeTag = 2147483647; #[doc = " SGR attribute tags.\n\n These values identify the type of an SGR attribute in a tagged union.\n Use the tag to determine which field in the attribute value union to access.\n\n @ingroup sgr"] pub type GhosttySgrAttributeTag = ::std::os::raw::c_uint; pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_NONE: GhosttySgrUnderline = 0; @@ -1879,6 +3367,7 @@ pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_DOUBLE: GhosttySgrUnderline pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_CURLY: GhosttySgrUnderline = 3; pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_DOTTED: GhosttySgrUnderline = 4; pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_DASHED: GhosttySgrUnderline = 5; +pub const GhosttySgrUnderline_GHOSTTY_SGR_UNDERLINE_MAX_VALUE: GhosttySgrUnderline = 2147483647; #[doc = " Underline style types.\n\n @ingroup sgr"] pub type GhosttySgrUnderline = ::std::os::raw::c_uint; #[doc = " Unknown SGR attribute data.\n\n Contains the full parameter list and the partial list where parsing\n encountered an unknown or invalid sequence.\n\n @ingroup sgr"] @@ -2001,21 +3490,21 @@ unsafe extern "C" { #[doc = " Create a new SGR parser instance.\n\n Creates a new SGR (Select Graphic Rendition) parser using the provided\n allocator. The parser must be freed using ghostty_sgr_free() when\n no longer needed.\n\n @param allocator Pointer to the allocator to use for memory management, or\n NULL to use the default allocator\n @param parser Pointer to store the created parser handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup sgr"] pub fn ghostty_sgr_new( allocator: *const GhosttyAllocator, - parser: *mut GhosttySgrParser_ptr, + parser: *mut GhosttySgrParser, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free an SGR parser instance.\n\n Releases all resources associated with the SGR parser. After this call,\n the parser handle becomes invalid and must not be used. This includes\n any attributes previously returned by ghostty_sgr_next().\n\n @param parser The parser handle to free (may be NULL)\n\n @ingroup sgr"] - pub fn ghostty_sgr_free(parser: GhosttySgrParser_ptr); + pub fn ghostty_sgr_free(parser: GhosttySgrParser); } unsafe extern "C" { #[doc = " Reset an SGR parser instance to the beginning of the parameter list.\n\n Resets the parser's iteration state without clearing the parameters.\n After calling this, ghostty_sgr_next() will start from the beginning\n of the parameter list again.\n\n @param parser The parser handle to reset, must not be NULL\n\n @ingroup sgr"] - pub fn ghostty_sgr_reset(parser: GhosttySgrParser_ptr); + pub fn ghostty_sgr_reset(parser: GhosttySgrParser); } unsafe extern "C" { #[doc = " Set SGR parameters for parsing.\n\n Sets the SGR parameter list to parse. Parameters are the numeric values\n from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).\n\n The separators array optionally specifies the separator type for each\n parameter position. Each byte should be either ';' for semicolon or ':'\n for colon. This is needed for certain color formats that use colon\n separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator\n values are treated as semicolons. The separators array must have the same\n length as the params array, if it is not NULL.\n\n If separators is NULL, all parameters are assumed to be semicolon-separated.\n\n This function makes an internal copy of the parameter and separator data,\n so the caller can safely free or modify the input arrays after this call.\n\n After calling this function, the parser is automatically reset and ready\n to iterate from the beginning.\n\n @param parser The parser handle, must not be NULL\n @param params Array of SGR parameter values\n @param separators Optional array of separator characters (';' or ':'), or\n NULL\n @param len Number of parameters (and separators if provided)\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup sgr"] pub fn ghostty_sgr_set_params( - parser: GhosttySgrParser_ptr, + parser: GhosttySgrParser, params: *const u16, separators: *const ::std::os::raw::c_char, len: usize, @@ -2023,7 +3512,7 @@ unsafe extern "C" { } unsafe extern "C" { #[doc = " Get the next SGR attribute.\n\n Parses and returns the next attribute from the parameter list.\n Call this function repeatedly until it returns false to process\n all attributes in the sequence.\n\n @param parser The parser handle, must not be NULL\n @param attr Pointer to store the next attribute\n @return true if an attribute was returned, false if no more attributes\n\n @ingroup sgr"] - pub fn ghostty_sgr_next(parser: GhosttySgrParser_ptr, attr: *mut GhosttySgrAttribute) -> bool; + pub fn ghostty_sgr_next(parser: GhosttySgrParser, attr: *mut GhosttySgrAttribute) -> bool; } unsafe extern "C" { #[doc = " Get the full parameter list from an unknown SGR attribute.\n\n This function retrieves the full parameter list that was provided to the\n parser when an unknown attribute was encountered. Primarily useful in\n WebAssembly environments where accessing struct fields directly is difficult.\n\n @param unknown The unknown attribute data\n @param ptr Pointer to store the pointer to the parameter array (may be NULL)\n @return The length of the full parameter array\n\n @ingroup sgr"] @@ -2043,19 +3532,112 @@ unsafe extern "C" { attr: *mut GhosttySgrAttribute, ) -> *mut GhosttySgrAttributeValue; } +#[doc = " Result of decoding an image.\n\n The `data` buffer must be allocated through the allocator provided to\n the decode callback. The library takes ownership and will free it\n with the same allocator."] #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct GhosttyKeyEvent { +pub struct GhosttySysImage { + #[doc = " Image width in pixels."] + pub width: u32, + #[doc = " Image height in pixels."] + pub height: u32, + #[doc = " Pointer to the decoded RGBA pixel data."] + pub data: *mut u8, + #[doc = " Length of the pixel data in bytes."] + pub data_len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySysImage"][::std::mem::size_of::<GhosttySysImage>() - 24usize]; + ["Alignment of GhosttySysImage"][::std::mem::align_of::<GhosttySysImage>() - 8usize]; + ["Offset of field: GhosttySysImage::width"] + [::std::mem::offset_of!(GhosttySysImage, width) - 0usize]; + ["Offset of field: GhosttySysImage::height"] + [::std::mem::offset_of!(GhosttySysImage, height) - 4usize]; + ["Offset of field: GhosttySysImage::data"] + [::std::mem::offset_of!(GhosttySysImage, data) - 8usize]; + ["Offset of field: GhosttySysImage::data_len"] + [::std::mem::offset_of!(GhosttySysImage, data_len) - 16usize]; +}; +impl Default for GhosttySysImage { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::<Self>::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub const GhosttySysLogLevel_GHOSTTY_SYS_LOG_LEVEL_ERROR: GhosttySysLogLevel = 0; +pub const GhosttySysLogLevel_GHOSTTY_SYS_LOG_LEVEL_WARNING: GhosttySysLogLevel = 1; +pub const GhosttySysLogLevel_GHOSTTY_SYS_LOG_LEVEL_INFO: GhosttySysLogLevel = 2; +pub const GhosttySysLogLevel_GHOSTTY_SYS_LOG_LEVEL_DEBUG: GhosttySysLogLevel = 3; +pub const GhosttySysLogLevel_GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE: GhosttySysLogLevel = 2147483647; +#[doc = " Log severity levels for the log callback."] +pub type GhosttySysLogLevel = ::std::os::raw::c_uint; +#[doc = " Callback type for logging.\n\n When installed, internal library log messages are delivered through\n this callback instead of being discarded. The embedder is responsible\n for formatting and routing log output.\n\n @p scope is the log scope name as UTF-8 bytes (e.g. \"osc\", \"kitty\").\n When the log is unscoped (default scope), @p scope_len is 0.\n\n All pointer arguments are only valid for the duration of the callback.\n The callback must be safe to call from any thread.\n\n @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA\n @param level The severity level of the log message\n @param scope Pointer to the scope name bytes\n @param scope_len Length of the scope name in bytes\n @param message Pointer to the log message bytes\n @param message_len Length of the log message in bytes"] +pub type GhosttySysLogFn = ::std::option::Option< + unsafe extern "C" fn( + userdata: *mut ::std::os::raw::c_void, + level: GhosttySysLogLevel, + scope: *const u8, + scope_len: usize, + message: *const u8, + message_len: usize, + ), +>; +#[doc = " Callback type for PNG decoding.\n\n Decodes raw PNG data into RGBA pixels. The output pixel data must be\n allocated through the provided allocator. The library takes ownership\n of the buffer and will free it with the same allocator.\n\n @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA\n @param allocator The allocator to use for the output pixel buffer\n @param data Pointer to the raw PNG data\n @param data_len Length of the raw PNG data in bytes\n @param[out] out On success, filled with the decoded image\n @return true on success, false on failure"] +pub type GhosttySysDecodePngFn = ::std::option::Option< + unsafe extern "C" fn( + userdata: *mut ::std::os::raw::c_void, + allocator: *const GhosttyAllocator, + data: *const u8, + data_len: usize, + out: *mut GhosttySysImage, + ) -> bool, +>; +#[doc = " Set the userdata pointer passed to all sys callbacks.\n\n Input type: void* (or NULL)"] +pub const GhosttySysOption_GHOSTTY_SYS_OPT_USERDATA: GhosttySysOption = 0; +#[doc = " Set the PNG decode function.\n\n When set, the terminal can accept PNG images via the Kitty\n Graphics Protocol. When cleared (NULL value), PNG decoding is\n unsupported and PNG image data will be rejected.\n\n Input type: GhosttySysDecodePngFn (function pointer, or NULL)"] +pub const GhosttySysOption_GHOSTTY_SYS_OPT_DECODE_PNG: GhosttySysOption = 1; +#[doc = " Set the log callback.\n\n When set, internal library log messages are delivered to this\n callback. When cleared (NULL value), log messages are silently\n discarded.\n\n Use ghostty_sys_log_stderr as a convenience callback that\n writes formatted messages to stderr.\n\n Which log levels are emitted depends on the build mode of the\n library and is not configurable at runtime. Debug builds emit\n all levels (debug and above). Release builds emit info and\n above; debug-level messages are compiled out entirely and will\n never reach the callback.\n\n Input type: GhosttySysLogFn (function pointer, or NULL)"] +pub const GhosttySysOption_GHOSTTY_SYS_OPT_LOG: GhosttySysOption = 2; +#[doc = " Set the log callback.\n\n When set, internal library log messages are delivered to this\n callback. When cleared (NULL value), log messages are silently\n discarded.\n\n Use ghostty_sys_log_stderr as a convenience callback that\n writes formatted messages to stderr.\n\n Which log levels are emitted depends on the build mode of the\n library and is not configurable at runtime. Debug builds emit\n all levels (debug and above). Release builds emit info and\n above; debug-level messages are compiled out entirely and will\n never reach the callback.\n\n Input type: GhosttySysLogFn (function pointer, or NULL)"] +pub const GhosttySysOption_GHOSTTY_SYS_OPT_MAX_VALUE: GhosttySysOption = 2147483647; +#[doc = " System option identifiers for ghostty_sys_set()."] +pub type GhosttySysOption = ::std::os::raw::c_uint; +unsafe extern "C" { + #[doc = " Set a system-level option.\n\n Configures a process-global implementation function. These should be\n set once at startup before using any terminal functionality that\n depends on them.\n\n @param option The option to set\n @param value Pointer to the value (type depends on the option),\n or NULL to clear it\n @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the\n option is not recognized"] + pub fn ghostty_sys_set( + option: GhosttySysOption, + value: *const ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + #[doc = " Built-in log callback that writes to stderr.\n\n Formats each message as \"[level](scope): message\\n\".\n Can be passed directly to ghostty_sys_set():\n\n @code\n ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr);\n @endcode"] + pub fn ghostty_sys_log_stderr( + userdata: *mut ::std::os::raw::c_void, + level: GhosttySysLogLevel, + scope: *const u8, + scope_len: usize, + message: *const u8, + message_len: usize, + ); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKeyEventImpl { _unused: [u8; 0], } #[doc = " Opaque handle to a key event.\n\n This handle represents a keyboard input event containing information about\n the physical key pressed, modifiers, and generated text.\n\n @ingroup key"] -pub type GhosttyKeyEvent_ptr = *mut GhosttyKeyEvent; +pub type GhosttyKeyEvent = *mut GhosttyKeyEventImpl; #[doc = " Key was released"] pub const GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE: GhosttyKeyAction = 0; #[doc = " Key was pressed"] pub const GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS: GhosttyKeyAction = 1; #[doc = " Key is being repeated (held down)"] pub const GhosttyKeyAction_GHOSTTY_KEY_ACTION_REPEAT: GhosttyKeyAction = 2; +#[doc = " Key is being repeated (held down)"] +pub const GhosttyKeyAction_GHOSTTY_KEY_ACTION_MAX_VALUE: GhosttyKeyAction = 2147483647; #[doc = " Keyboard input event types.\n\n @ingroup key"] pub type GhosttyKeyAction = ::std::os::raw::c_uint; #[doc = " Keyboard modifier keys bitmask.\n\n A bitmask representing all keyboard modifiers. This tracks which modifier keys\n are pressed and, where supported by the platform, which side (left or right)\n of each modifier is active.\n\n Use the GHOSTTY_MODS_* constants to test and set individual modifiers.\n\n Modifier side bits are only meaningful when the corresponding modifier bit is set.\n Not all platforms support distinguishing between left and right modifier\n keys and Ghostty is built to expect that some platforms may not provide this\n information.\n\n @ingroup key"] @@ -2236,66 +3818,64 @@ pub const GhosttyKey_GHOSTTY_KEY_WAKE_UP: GhosttyKey = 172; pub const GhosttyKey_GHOSTTY_KEY_COPY: GhosttyKey = 173; pub const GhosttyKey_GHOSTTY_KEY_CUT: GhosttyKey = 174; pub const GhosttyKey_GHOSTTY_KEY_PASTE: GhosttyKey = 175; +pub const GhosttyKey_GHOSTTY_KEY_MAX_VALUE: GhosttyKey = 2147483647; #[doc = " Physical key codes.\n\n The set of key codes that Ghostty is aware of. These represent physical keys\n on the keyboard and are layout-independent. For example, the \"a\" key on a US\n keyboard is the same as the \"ф\" key on a Russian keyboard, but both will\n report the same key_a value.\n\n Layout-dependent strings are provided separately as UTF-8 text and are produced\n by the platform. These values are based on the W3C UI Events KeyboardEvent code\n standard. See: https://www.w3.org/TR/uievents-code\n\n @ingroup key"] pub type GhosttyKey = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Create a new key event instance.\n\n Creates a new key event with default values. The event must be freed using\n ghostty_key_event_free() when no longer needed.\n\n @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n @param event Pointer to store the created key event handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup key"] pub fn ghostty_key_event_new( allocator: *const GhosttyAllocator, - event: *mut GhosttyKeyEvent_ptr, + event: *mut GhosttyKeyEvent, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a key event instance.\n\n Releases all resources associated with the key event. After this call,\n the event handle becomes invalid and must not be used.\n\n @param event The key event handle to free (may be NULL)\n\n @ingroup key"] - pub fn ghostty_key_event_free(event: GhosttyKeyEvent_ptr); + pub fn ghostty_key_event_free(event: GhosttyKeyEvent); } unsafe extern "C" { #[doc = " Set the key action (press, release, repeat).\n\n @param event The key event handle, must not be NULL\n @param action The action to set\n\n @ingroup key"] - pub fn ghostty_key_event_set_action(event: GhosttyKeyEvent_ptr, action: GhosttyKeyAction); + pub fn ghostty_key_event_set_action(event: GhosttyKeyEvent, action: GhosttyKeyAction); } unsafe extern "C" { #[doc = " Get the key action (press, release, repeat).\n\n @param event The key event handle, must not be NULL\n @return The key action\n\n @ingroup key"] - pub fn ghostty_key_event_get_action(event: GhosttyKeyEvent_ptr) -> GhosttyKeyAction; + pub fn ghostty_key_event_get_action(event: GhosttyKeyEvent) -> GhosttyKeyAction; } unsafe extern "C" { #[doc = " Set the physical key code.\n\n @param event The key event handle, must not be NULL\n @param key The physical key code to set\n\n @ingroup key"] - pub fn ghostty_key_event_set_key(event: GhosttyKeyEvent_ptr, key: GhosttyKey); + pub fn ghostty_key_event_set_key(event: GhosttyKeyEvent, key: GhosttyKey); } unsafe extern "C" { #[doc = " Get the physical key code.\n\n @param event The key event handle, must not be NULL\n @return The physical key code\n\n @ingroup key"] - pub fn ghostty_key_event_get_key(event: GhosttyKeyEvent_ptr) -> GhosttyKey; + pub fn ghostty_key_event_get_key(event: GhosttyKeyEvent) -> GhosttyKey; } unsafe extern "C" { #[doc = " Set the modifier keys bitmask.\n\n @param event The key event handle, must not be NULL\n @param mods The modifier keys bitmask to set\n\n @ingroup key"] - pub fn ghostty_key_event_set_mods(event: GhosttyKeyEvent_ptr, mods: GhosttyMods); + pub fn ghostty_key_event_set_mods(event: GhosttyKeyEvent, mods: GhosttyMods); } unsafe extern "C" { #[doc = " Get the modifier keys bitmask.\n\n @param event The key event handle, must not be NULL\n @return The modifier keys bitmask\n\n @ingroup key"] - pub fn ghostty_key_event_get_mods(event: GhosttyKeyEvent_ptr) -> GhosttyMods; + pub fn ghostty_key_event_get_mods(event: GhosttyKeyEvent) -> GhosttyMods; } unsafe extern "C" { #[doc = " Set the consumed modifiers bitmask.\n\n @param event The key event handle, must not be NULL\n @param consumed_mods The consumed modifiers bitmask to set\n\n @ingroup key"] - pub fn ghostty_key_event_set_consumed_mods( - event: GhosttyKeyEvent_ptr, - consumed_mods: GhosttyMods, - ); + pub fn ghostty_key_event_set_consumed_mods(event: GhosttyKeyEvent, consumed_mods: GhosttyMods); } unsafe extern "C" { #[doc = " Get the consumed modifiers bitmask.\n\n @param event The key event handle, must not be NULL\n @return The consumed modifiers bitmask\n\n @ingroup key"] - pub fn ghostty_key_event_get_consumed_mods(event: GhosttyKeyEvent_ptr) -> GhosttyMods; + pub fn ghostty_key_event_get_consumed_mods(event: GhosttyKeyEvent) -> GhosttyMods; } unsafe extern "C" { #[doc = " Set whether the key event is part of a composition sequence.\n\n @param event The key event handle, must not be NULL\n @param composing Whether the key event is part of a composition sequence\n\n @ingroup key"] - pub fn ghostty_key_event_set_composing(event: GhosttyKeyEvent_ptr, composing: bool); + pub fn ghostty_key_event_set_composing(event: GhosttyKeyEvent, composing: bool); } unsafe extern "C" { #[doc = " Get whether the key event is part of a composition sequence.\n\n @param event The key event handle, must not be NULL\n @return Whether the key event is part of a composition sequence\n\n @ingroup key"] - pub fn ghostty_key_event_get_composing(event: GhosttyKeyEvent_ptr) -> bool; + pub fn ghostty_key_event_get_composing(event: GhosttyKeyEvent) -> bool; } unsafe extern "C" { - #[doc = " Set the UTF-8 text generated by the key event.\n\n The key event does NOT take ownership of the text pointer. The caller\n must ensure the string remains valid for the lifetime needed by the event.\n\n @param event The key event handle, must not be NULL\n @param utf8 The UTF-8 text to set (or NULL for empty)\n @param len Length of the UTF-8 text in bytes\n\n @ingroup key"] + #[doc = " Set the UTF-8 text generated by the key for the current keyboard layout.\n\n Must contain the unmodified character before any Ctrl/Meta transformations.\n The encoder derives modifier sequences from the logical key and mods\n bitmask, not from this text. Do not pass C0 control characters\n (U+0000-U+001F, U+007F) or platform function key codes (e.g. macOS PUA\n U+F700-U+F8FF); pass NULL instead and let the encoder use the logical key.\n\n The key event does NOT take ownership of the text pointer. The caller\n must ensure the string remains valid for the lifetime needed by the event.\n\n @param event The key event handle, must not be NULL\n @param utf8 The UTF-8 text to set (or NULL for empty)\n @param len Length of the UTF-8 text in bytes\n\n @ingroup key"] pub fn ghostty_key_event_set_utf8( - event: GhosttyKeyEvent_ptr, + event: GhosttyKeyEvent, utf8: *const ::std::os::raw::c_char, len: usize, ); @@ -2303,25 +3883,25 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Get the UTF-8 text generated by the key event.\n\n The returned pointer is valid until the event is freed or the UTF-8 text is modified.\n\n @param event The key event handle, must not be NULL\n @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)\n @return The UTF-8 text (or NULL for empty)\n\n @ingroup key"] pub fn ghostty_key_event_get_utf8( - event: GhosttyKeyEvent_ptr, + event: GhosttyKeyEvent, len: *mut usize, ) -> *const ::std::os::raw::c_char; } unsafe extern "C" { #[doc = " Set the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @param codepoint The unshifted Unicode codepoint to set\n\n @ingroup key"] - pub fn ghostty_key_event_set_unshifted_codepoint(event: GhosttyKeyEvent_ptr, codepoint: u32); + pub fn ghostty_key_event_set_unshifted_codepoint(event: GhosttyKeyEvent, codepoint: u32); } unsafe extern "C" { #[doc = " Get the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @return The unshifted Unicode codepoint\n\n @ingroup key"] - pub fn ghostty_key_event_get_unshifted_codepoint(event: GhosttyKeyEvent_ptr) -> u32; + pub fn ghostty_key_event_get_unshifted_codepoint(event: GhosttyKeyEvent) -> u32; } #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct GhosttyKeyEncoder { +pub struct GhosttyKeyEncoderImpl { _unused: [u8; 0], } #[doc = " Opaque handle to a key encoder instance.\n\n This handle represents a key encoder that converts key events into terminal\n escape sequences.\n\n @ingroup key"] -pub type GhosttyKeyEncoder_ptr = *mut GhosttyKeyEncoder; +pub type GhosttyKeyEncoder = *mut GhosttyKeyEncoderImpl; #[doc = " Kitty keyboard protocol flags.\n\n Bitflags representing the various modes of the Kitty keyboard protocol.\n These can be combined using bitwise OR operations. Valid values all\n start with `GHOSTTY_KITTY_KEY_`.\n\n @ingroup key"] pub type GhosttyKittyKeyFlags = u8; #[doc = " Option key is not treated as alt"] @@ -2332,6 +3912,8 @@ pub const GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_TRUE: GhosttyOptionAsAlt = 1; pub const GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_LEFT: GhosttyOptionAsAlt = 2; #[doc = " Only right option key is treated as alt"] pub const GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_RIGHT: GhosttyOptionAsAlt = 3; +#[doc = " Only right option key is treated as alt"] +pub const GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_MAX_VALUE: GhosttyOptionAsAlt = 2147483647; #[doc = " macOS option key behavior.\n\n Determines whether the \"option\" key on macOS is treated as \"alt\" or not.\n See the Ghostty `macos-option-as-alt` configuration option for more details.\n\n @ingroup key"] pub type GhosttyOptionAsAlt = ::std::os::raw::c_uint; #[doc = " Terminal DEC mode 1: cursor key application mode (value: bool)"] @@ -2354,23 +3936,29 @@ pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS: GhosttyKe #[doc = " macOS option-as-alt setting (value: GhosttyOptionAsAlt)"] pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT: GhosttyKeyEncoderOption = 6; +#[doc = " Backarrow key mode (value: bool)\n See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170\n If `false` (the default), `backspace` emits 0x7f\n If `true`, `backspace` emits 0x08"] +pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE: + GhosttyKeyEncoderOption = 7; +#[doc = " Backarrow key mode (value: bool)\n See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170\n If `false` (the default), `backspace` emits 0x7f\n If `true`, `backspace` emits 0x08"] +pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE: GhosttyKeyEncoderOption = + 2147483647; #[doc = " Key encoder option identifiers.\n\n These values are used with ghostty_key_encoder_setopt() to configure\n the behavior of the key encoder.\n\n @ingroup key"] pub type GhosttyKeyEncoderOption = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Create a new key encoder instance.\n\n Creates a new key encoder with default options. The encoder can be configured\n using ghostty_key_encoder_setopt() and must be freed using\n ghostty_key_encoder_free() when no longer needed.\n\n @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n @param encoder Pointer to store the created encoder handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup key"] pub fn ghostty_key_encoder_new( allocator: *const GhosttyAllocator, - encoder: *mut GhosttyKeyEncoder_ptr, + encoder: *mut GhosttyKeyEncoder, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a key encoder instance.\n\n Releases all resources associated with the key encoder. After this call,\n the encoder handle becomes invalid and must not be used.\n\n @param encoder The encoder handle to free (may be NULL)\n\n @ingroup key"] - pub fn ghostty_key_encoder_free(encoder: GhosttyKeyEncoder_ptr); + pub fn ghostty_key_encoder_free(encoder: GhosttyKeyEncoder); } unsafe extern "C" { #[doc = " Set an option on the key encoder.\n\n Configures the behavior of the key encoder. Options control various aspects\n of encoding such as terminal modes (cursor key application mode, keypad mode),\n protocol selection (Kitty keyboard protocol flags), and platform-specific\n behaviors (macOS option-as-alt).\n\n If you are using a terminal instance, you can set the key encoding\n options based on the active terminal state (e.g. legacy vs Kitty mode\n and associated flags) with ghostty_key_encoder_setopt_from_terminal().\n\n A null pointer value does nothing. It does not reset the value to the\n default. The setopt call will do nothing.\n\n @param encoder The encoder handle, must not be NULL\n @param option The option to set\n @param value Pointer to the value to set (type depends on the option)\n\n @ingroup key"] pub fn ghostty_key_encoder_setopt( - encoder: GhosttyKeyEncoder_ptr, + encoder: GhosttyKeyEncoder, option: GhosttyKeyEncoderOption, value: *const ::std::os::raw::c_void, ); @@ -2378,15 +3966,15 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set encoder options from a terminal's current state.\n\n Reads the terminal's current modes and flags and applies them to the\n encoder's options. This sets cursor key application mode, keypad mode,\n alt escape prefix, modifyOtherKeys state, and Kitty keyboard protocol\n flags from the terminal state.\n\n Note that the `macos_option_as_alt` option cannot be determined from\n terminal state and is reset to `GHOSTTY_OPTION_AS_ALT_FALSE` by this\n call. Use ghostty_key_encoder_setopt() to set it afterward if needed.\n\n @param encoder The encoder handle, must not be NULL\n @param terminal The terminal handle, must not be NULL\n\n @ingroup key"] pub fn ghostty_key_encoder_setopt_from_terminal( - encoder: GhosttyKeyEncoder_ptr, - terminal: GhosttyTerminal_ptr, + encoder: GhosttyKeyEncoder, + terminal: GhosttyTerminal, ); } unsafe extern "C" { #[doc = " Encode a key event into a terminal escape sequence.\n\n Converts a key event into the appropriate terminal escape sequence based on\n the encoder's current options. The sequence is written to the provided buffer.\n\n Not all key events produce output. For example, unmodified modifier keys\n typically don't generate escape sequences. Check the out_len parameter to\n determine if any data was written.\n\n If the output buffer is too small, this function returns GHOSTTY_OUT_OF_SPACE\n and out_len will contain the required buffer size. The caller can then\n allocate a larger buffer and call the function again.\n\n @param encoder The encoder handle, must not be NULL\n @param event The key event to encode, must not be NULL\n @param out_buf Buffer to write the encoded sequence to\n @param out_buf_size Size of the output buffer in bytes\n @param out_len Pointer to store the number of bytes written (may be NULL)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer too small, or other error code\n\n ## Example: Calculate required buffer size\n\n @code{.c}\n // Query the required size with a NULL buffer (always returns OUT_OF_SPACE)\n size_t required = 0;\n GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);\n assert(result == GHOSTTY_OUT_OF_SPACE);\n\n // Allocate buffer of required size\n char *buf = malloc(required);\n\n // Encode with properly sized buffer\n size_t written = 0;\n result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);\n assert(result == GHOSTTY_SUCCESS);\n\n // Use the encoded sequence...\n\n free(buf);\n @endcode\n\n ## Example: Direct encoding with static buffer\n\n @code{.c}\n // Most escape sequences are short, so a static buffer often suffices\n char buf[128];\n size_t written = 0;\n GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);\n\n if (result == GHOSTTY_SUCCESS) {\n // Write the encoded sequence to the terminal\n write(pty_fd, buf, written);\n } else if (result == GHOSTTY_OUT_OF_SPACE) {\n // Buffer too small, written contains required size\n char *dynamic_buf = malloc(written);\n result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);\n assert(result == GHOSTTY_SUCCESS);\n write(pty_fd, dynamic_buf, written);\n free(dynamic_buf);\n }\n @endcode\n\n @ingroup key"] pub fn ghostty_key_encoder_encode( - encoder: GhosttyKeyEncoder_ptr, - event: GhosttyKeyEvent_ptr, + encoder: GhosttyKeyEncoder, + event: GhosttyKeyEvent, out_buf: *mut ::std::os::raw::c_char, out_buf_size: usize, out_len: *mut usize, @@ -2394,17 +3982,19 @@ unsafe extern "C" { } #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct GhosttyMouseEvent { +pub struct GhosttyMouseEventImpl { _unused: [u8; 0], } #[doc = " Opaque handle to a mouse event.\n\n This handle represents a normalized mouse input event containing\n action, button, modifiers, and surface-space position.\n\n @ingroup mouse"] -pub type GhosttyMouseEvent_ptr = *mut GhosttyMouseEvent; +pub type GhosttyMouseEvent = *mut GhosttyMouseEventImpl; #[doc = " Mouse button was pressed."] pub const GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_PRESS: GhosttyMouseAction = 0; #[doc = " Mouse button was released."] pub const GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_RELEASE: GhosttyMouseAction = 1; #[doc = " Mouse moved."] pub const GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_MOTION: GhosttyMouseAction = 2; +#[doc = " Mouse moved."] +pub const GhosttyMouseAction_GHOSTTY_MOUSE_ACTION_MAX_VALUE: GhosttyMouseAction = 2147483647; #[doc = " Mouse event action type.\n\n @ingroup mouse"] pub type GhosttyMouseAction = ::std::os::raw::c_uint; pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_UNKNOWN: GhosttyMouseButton = 0; @@ -2419,6 +4009,7 @@ pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_EIGHT: GhosttyMouseButton = 8; pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_NINE: GhosttyMouseButton = 9; pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_TEN: GhosttyMouseButton = 10; pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_ELEVEN: GhosttyMouseButton = 11; +pub const GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_MAX_VALUE: GhosttyMouseButton = 2147483647; #[doc = " Mouse button identity.\n\n @ingroup mouse"] pub type GhosttyMouseButton = ::std::os::raw::c_uint; #[doc = " Mouse position in surface-space pixels.\n\n @ingroup mouse"] @@ -2441,62 +4032,62 @@ unsafe extern "C" { #[doc = " Create a new mouse event instance.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param event Pointer to store the created event handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup mouse"] pub fn ghostty_mouse_event_new( allocator: *const GhosttyAllocator, - event: *mut GhosttyMouseEvent_ptr, + event: *mut GhosttyMouseEvent, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a mouse event instance.\n\n @param event The mouse event handle to free (may be NULL)\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_free(event: GhosttyMouseEvent_ptr); + pub fn ghostty_mouse_event_free(event: GhosttyMouseEvent); } unsafe extern "C" { #[doc = " Set the event action.\n\n @param event The event handle, must not be NULL\n @param action The action to set\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_set_action(event: GhosttyMouseEvent_ptr, action: GhosttyMouseAction); + pub fn ghostty_mouse_event_set_action(event: GhosttyMouseEvent, action: GhosttyMouseAction); } unsafe extern "C" { #[doc = " Get the event action.\n\n @param event The event handle, must not be NULL\n @return The event action\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_get_action(event: GhosttyMouseEvent_ptr) -> GhosttyMouseAction; + pub fn ghostty_mouse_event_get_action(event: GhosttyMouseEvent) -> GhosttyMouseAction; } unsafe extern "C" { #[doc = " Set the event button.\n\n This sets a concrete button identity for the event.\n To represent \"no button\" (for motion events), use\n ghostty_mouse_event_clear_button().\n\n @param event The event handle, must not be NULL\n @param button The button to set\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_set_button(event: GhosttyMouseEvent_ptr, button: GhosttyMouseButton); + pub fn ghostty_mouse_event_set_button(event: GhosttyMouseEvent, button: GhosttyMouseButton); } unsafe extern "C" { #[doc = " Clear the event button.\n\n This sets the event button to \"none\".\n\n @param event The event handle, must not be NULL\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_clear_button(event: GhosttyMouseEvent_ptr); + pub fn ghostty_mouse_event_clear_button(event: GhosttyMouseEvent); } unsafe extern "C" { #[doc = " Get the event button.\n\n @param event The event handle, must not be NULL\n @param out_button Output pointer for the button value (may be NULL)\n @return true if a button is set, false if no button is set\n\n @ingroup mouse"] pub fn ghostty_mouse_event_get_button( - event: GhosttyMouseEvent_ptr, + event: GhosttyMouseEvent, out_button: *mut GhosttyMouseButton, ) -> bool; } unsafe extern "C" { #[doc = " Set keyboard modifiers held during the event.\n\n @param event The event handle, must not be NULL\n @param mods Modifier bitmask\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_set_mods(event: GhosttyMouseEvent_ptr, mods: GhosttyMods); + pub fn ghostty_mouse_event_set_mods(event: GhosttyMouseEvent, mods: GhosttyMods); } unsafe extern "C" { #[doc = " Get keyboard modifiers held during the event.\n\n @param event The event handle, must not be NULL\n @return Modifier bitmask\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_get_mods(event: GhosttyMouseEvent_ptr) -> GhosttyMods; + pub fn ghostty_mouse_event_get_mods(event: GhosttyMouseEvent) -> GhosttyMods; } unsafe extern "C" { #[doc = " Set the event position in surface-space pixels.\n\n @param event The event handle, must not be NULL\n @param position The position to set\n\n @ingroup mouse"] pub fn ghostty_mouse_event_set_position( - event: GhosttyMouseEvent_ptr, + event: GhosttyMouseEvent, position: GhosttyMousePosition, ); } unsafe extern "C" { #[doc = " Get the event position in surface-space pixels.\n\n @param event The event handle, must not be NULL\n @return The current event position\n\n @ingroup mouse"] - pub fn ghostty_mouse_event_get_position(event: GhosttyMouseEvent_ptr) -> GhosttyMousePosition; + pub fn ghostty_mouse_event_get_position(event: GhosttyMouseEvent) -> GhosttyMousePosition; } #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct GhosttyMouseEncoder { +pub struct GhosttyMouseEncoderImpl { _unused: [u8; 0], } #[doc = " Opaque handle to a mouse encoder instance.\n\n This handle represents a mouse encoder that converts normalized\n mouse events into terminal escape sequences.\n\n @ingroup mouse"] -pub type GhosttyMouseEncoder_ptr = *mut GhosttyMouseEncoder; +pub type GhosttyMouseEncoder = *mut GhosttyMouseEncoderImpl; #[doc = " Mouse reporting disabled."] pub const GhosttyMouseTrackingMode_GHOSTTY_MOUSE_TRACKING_NONE: GhosttyMouseTrackingMode = 0; #[doc = " X10 mouse mode."] @@ -2507,6 +4098,9 @@ pub const GhosttyMouseTrackingMode_GHOSTTY_MOUSE_TRACKING_NORMAL: GhosttyMouseTr pub const GhosttyMouseTrackingMode_GHOSTTY_MOUSE_TRACKING_BUTTON: GhosttyMouseTrackingMode = 3; #[doc = " Any-event tracking mode."] pub const GhosttyMouseTrackingMode_GHOSTTY_MOUSE_TRACKING_ANY: GhosttyMouseTrackingMode = 4; +#[doc = " Any-event tracking mode."] +pub const GhosttyMouseTrackingMode_GHOSTTY_MOUSE_TRACKING_MAX_VALUE: GhosttyMouseTrackingMode = + 2147483647; #[doc = " Mouse tracking mode.\n\n @ingroup mouse"] pub type GhosttyMouseTrackingMode = ::std::os::raw::c_uint; pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_X10: GhosttyMouseFormat = 0; @@ -2514,6 +4108,7 @@ pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_UTF8: GhosttyMouseFormat = 1; pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR: GhosttyMouseFormat = 2; pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_URXVT: GhosttyMouseFormat = 3; pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR_PIXELS: GhosttyMouseFormat = 4; +pub const GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_MAX_VALUE: GhosttyMouseFormat = 2147483647; #[doc = " Mouse output format.\n\n @ingroup mouse"] pub type GhosttyMouseFormat = ::std::os::raw::c_uint; #[doc = " Mouse encoder size and geometry context.\n\n This describes the rendered terminal geometry used to convert\n surface-space positions into encoded coordinates.\n\n @ingroup mouse"] @@ -2575,23 +4170,26 @@ pub const GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED #[doc = " Whether to enable motion deduplication by last cell (value: bool)."] pub const GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL: GhosttyMouseEncoderOption = 4; +#[doc = " Whether to enable motion deduplication by last cell (value: bool)."] +pub const GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE: GhosttyMouseEncoderOption = + 2147483647; #[doc = " Mouse encoder option identifiers.\n\n These values are used with ghostty_mouse_encoder_setopt() to configure\n the behavior of the mouse encoder.\n\n @ingroup mouse"] pub type GhosttyMouseEncoderOption = ::std::os::raw::c_uint; unsafe extern "C" { #[doc = " Create a new mouse encoder instance.\n\n @param allocator Pointer to allocator, or NULL to use the default allocator\n @param encoder Pointer to store the created encoder handle\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup mouse"] pub fn ghostty_mouse_encoder_new( allocator: *const GhosttyAllocator, - encoder: *mut GhosttyMouseEncoder_ptr, + encoder: *mut GhosttyMouseEncoder, ) -> GhosttyResult; } unsafe extern "C" { #[doc = " Free a mouse encoder instance.\n\n @param encoder The encoder handle to free (may be NULL)\n\n @ingroup mouse"] - pub fn ghostty_mouse_encoder_free(encoder: GhosttyMouseEncoder_ptr); + pub fn ghostty_mouse_encoder_free(encoder: GhosttyMouseEncoder); } unsafe extern "C" { #[doc = " Set an option on the mouse encoder.\n\n A null pointer value does nothing. It does not reset to defaults.\n\n @param encoder The encoder handle, must not be NULL\n @param option The option to set\n @param value Pointer to option value (type depends on option)\n\n @ingroup mouse"] pub fn ghostty_mouse_encoder_setopt( - encoder: GhosttyMouseEncoder_ptr, + encoder: GhosttyMouseEncoder, option: GhosttyMouseEncoderOption, value: *const ::std::os::raw::c_void, ); @@ -2599,19 +4197,19 @@ unsafe extern "C" { unsafe extern "C" { #[doc = " Set encoder options from a terminal's current state.\n\n This sets tracking mode and output format from terminal state.\n It does not modify size or any-button state.\n\n @param encoder The encoder handle, must not be NULL\n @param terminal The terminal handle, must not be NULL\n\n @ingroup mouse"] pub fn ghostty_mouse_encoder_setopt_from_terminal( - encoder: GhosttyMouseEncoder_ptr, - terminal: GhosttyTerminal_ptr, + encoder: GhosttyMouseEncoder, + terminal: GhosttyTerminal, ); } unsafe extern "C" { #[doc = " Reset internal encoder state.\n\n This clears motion deduplication state (last tracked cell).\n\n @param encoder The encoder handle (may be NULL)\n\n @ingroup mouse"] - pub fn ghostty_mouse_encoder_reset(encoder: GhosttyMouseEncoder_ptr); + pub fn ghostty_mouse_encoder_reset(encoder: GhosttyMouseEncoder); } unsafe extern "C" { #[doc = " Encode a mouse event into a terminal escape sequence.\n\n Not all mouse events produce output. In such cases this returns\n GHOSTTY_SUCCESS with out_len set to 0.\n\n If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE\n and out_len contains the required size.\n\n @param encoder The encoder handle, must not be NULL\n @param event The mouse event to encode, must not be NULL\n @param out_buf Buffer to write encoded bytes to, or NULL to query required size\n @param out_buf_size Size of out_buf in bytes\n @param out_len Pointer to store bytes written (or required bytes on failure)\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small,\n or another error code\n\n @ingroup mouse"] pub fn ghostty_mouse_encoder_encode( - encoder: GhosttyMouseEncoder_ptr, - event: GhosttyMouseEvent_ptr, + encoder: GhosttyMouseEncoder, + event: GhosttyMouseEvent, out_buf: *mut ::std::os::raw::c_char, out_buf_size: usize, out_len: *mut usize, @@ -2621,170 +4219,22 @@ unsafe extern "C" { #[doc = " Check if paste data is safe to paste into the terminal.\n\n Data is considered unsafe if it contains:\n - Newlines (`\\n`) which can inject commands\n - The bracketed paste end sequence (`\\x1b[201~`) which can be used\n to exit bracketed paste mode and inject commands\n\n This check is conservative and considers data unsafe regardless of\n current terminal state.\n\n @param data The paste data to check (must not be NULL)\n @param len The length of the data in bytes\n @return true if the data is safe to paste, false otherwise"] pub fn ghostty_paste_is_safe(data: *const ::std::os::raw::c_char, len: usize) -> bool; } - -// Manually maintained additions for the vendored libghostty-vt Kitty graphics -// C API. The rest of this file is generated by bindgen; keep these constants -// in sync with vendor/libghostty-vt/include/ghostty/vt/*.h until bindings are -// regenerated from the current vendored headers. -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT: - GhosttyTerminalOption = 15; -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE: - GhosttyTerminalOption = 16; -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE: - GhosttyTerminalOption = 17; -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM: - GhosttyTerminalOption = 18; -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES: GhosttyTerminalOption = 19; -pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY: GhosttyTerminalOption = - 20; - -pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE: GhosttyTerminalData = - 27; -pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE: - GhosttyTerminalData = 28; -pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM: - GhosttyTerminalData = 29; -pub const GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS: GhosttyTerminalData = 30; - -pub type GhosttySysOption = ::std::os::raw::c_uint; -pub const GhosttySysOption_GHOSTTY_SYS_OPT_DECODE_PNG: GhosttySysOption = 1; - -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyKittyGraphicsImpl { - _unused: [u8; 0], -} -pub type GhosttyKittyGraphics = *mut GhosttyKittyGraphicsImpl; - -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyKittyGraphicsImageImpl { - _unused: [u8; 0], -} -pub type GhosttyKittyGraphicsImage = *const GhosttyKittyGraphicsImageImpl; - -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct GhosttyKittyGraphicsPlacementIteratorImpl { - _unused: [u8; 0], -} -pub type GhosttyKittyGraphicsPlacementIterator = *mut GhosttyKittyGraphicsPlacementIteratorImpl; - -pub type GhosttyKittyGraphicsData = ::std::os::raw::c_uint; -pub const GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR: - GhosttyKittyGraphicsData = 1; - -pub type GhosttyKittyGraphicsPlacementData = ::std::os::raw::c_uint; -pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID: - GhosttyKittyGraphicsPlacementData = 1; -pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID: - GhosttyKittyGraphicsPlacementData = 2; -pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET: - GhosttyKittyGraphicsPlacementData = 4; -pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET: - GhosttyKittyGraphicsPlacementData = 5; -pub const GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z: - GhosttyKittyGraphicsPlacementData = 12; - -pub type GhosttyKittyGraphicsImageData = ::std::os::raw::c_uint; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_WIDTH: - GhosttyKittyGraphicsImageData = 3; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_HEIGHT: - GhosttyKittyGraphicsImageData = 4; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT: - GhosttyKittyGraphicsImageData = 5; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION: - GhosttyKittyGraphicsImageData = 6; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR: - GhosttyKittyGraphicsImageData = 7; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN: - GhosttyKittyGraphicsImageData = 8; -pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS: - GhosttyKittyGraphicsImageData = 9; - -pub type GhosttyKittyImageFormat = ::std::os::raw::c_uint; -pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGB: GhosttyKittyImageFormat = 0; -pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_RGBA: GhosttyKittyImageFormat = 1; -pub const GhosttyKittyImageFormat_GHOSTTY_KITTY_IMAGE_FORMAT_PNG: GhosttyKittyImageFormat = 2; - -pub type GhosttyKittyImageCompression = ::std::os::raw::c_uint; -pub const GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE: - GhosttyKittyImageCompression = 0; - -#[repr(C)] -#[derive(Debug, Default, Copy, Clone)] -pub struct GhosttyKittyGraphicsPlacementRenderInfo { - pub size: usize, - pub pixel_width: u32, - pub pixel_height: u32, - pub grid_cols: u32, - pub grid_rows: u32, - pub viewport_col: i32, - pub viewport_row: i32, - pub viewport_visible: bool, - pub source_x: u32, - pub source_y: u32, - pub source_width: u32, - pub source_height: u32, +unsafe extern "C" { + #[doc = " Encode paste data for writing to the terminal pty.\n\n This function prepares paste data for terminal input by:\n - Stripping unsafe control bytes (NUL, ESC, DEL, etc.) by replacing\n them with spaces\n - Wrapping the data in bracketed paste sequences if @p bracketed is true\n - Replacing newlines with carriage returns if @p bracketed is false\n\n The input @p data buffer is modified in place during encoding. The\n encoded result (potentially with bracketed paste prefix/suffix) is\n written to the output buffer.\n\n If the output buffer is too small, the function returns\n GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written.\n The caller can then retry with a sufficiently sized buffer.\n\n @param data The paste data to encode (modified in place, may be NULL)\n @param data_len The length of the input data in bytes\n @param bracketed Whether bracketed paste mode is active\n @param buf Output buffer to write the encoded result into (may be NULL)\n @param buf_len Size of the output buffer in bytes\n @param[out] out_written On success, the number of bytes written. On\n GHOSTTY_OUT_OF_SPACE, the required buffer size.\n @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer\n is too small"] + pub fn ghostty_paste_encode( + data: *mut ::std::os::raw::c_char, + data_len: usize, + bracketed: bool, + buf: *mut ::std::os::raw::c_char, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; } - -#[repr(C)] -#[derive(Debug, Default, Copy, Clone)] -pub struct GhosttySysImage { - pub width: u32, - pub height: u32, - pub data: *mut u8, - pub data_len: usize, +unsafe extern "C" { + #[doc = " Returns the terminal display width of a Unicode codepoint in\n terminal grid cells: 0, 1, or 2.\n\n This is the same width table the terminal itself uses when laying\n out printed text, so callers can predict column layout (e.g. IME\n preedit overlays) that exactly matches what the terminal will do\n when the text is actually written to it.\n\n Semantics:\n - Returns 0 for zero-width codepoints: C0/C1 control characters,\n nonspacing and enclosing combining marks, default-ignorable\n codepoints (ZWJ, ZWNJ, variation selectors, etc.), and\n surrogate codepoints.\n - Returns 2 for wide codepoints: East Asian Wide/Fullwidth\n (including emoji with default emoji presentation) and regional\n indicators. Width is clamped to 2 (e.g. the three-em dash).\n - Returns 1 for everything else, including invalid codepoints\n beyond U+10FFFF (this function is total; it never fails).\n\n This operates on a single codepoint only and therefore cannot account\n for grapheme-cluster-level width rules (VS16 emoji presentation,\n combining sequences, etc.). For cluster-accurate widths, use\n ghostty_unicode_grapheme_width(). Summing per-codepoint widths is only\n correct when mode 2027 (grapheme clustering) is disabled.\n\n This function is pure, allocates nothing, and is thread-safe.\n\n @param cp The Unicode codepoint to measure\n @return Display width in cells: 0, 1, or 2"] + pub fn ghostty_unicode_codepoint_width(cp: u32) -> u8; } - -pub type GhosttySysDecodePngFn = ::std::option::Option< - unsafe extern "C" fn( - userdata: *mut ::std::os::raw::c_void, - allocator: *const GhosttyAllocator, - data: *const u8, - data_len: usize, - out: *mut GhosttySysImage, - ) -> bool, ->; - unsafe extern "C" { - pub fn ghostty_sys_set( - option: GhosttySysOption, - value: *const ::std::os::raw::c_void, - ) -> GhosttyResult; - pub fn ghostty_kitty_graphics_get( - graphics: GhosttyKittyGraphics, - data: GhosttyKittyGraphicsData, - out: *mut ::std::os::raw::c_void, - ) -> GhosttyResult; - pub fn ghostty_kitty_graphics_image( - graphics: GhosttyKittyGraphics, - image_id: u32, - ) -> GhosttyKittyGraphicsImage; - pub fn ghostty_kitty_graphics_image_get( - image: GhosttyKittyGraphicsImage, - data: GhosttyKittyGraphicsImageData, - out: *mut ::std::os::raw::c_void, - ) -> GhosttyResult; - pub fn ghostty_kitty_graphics_placement_iterator_new( - allocator: *const GhosttyAllocator, - out_iterator: *mut GhosttyKittyGraphicsPlacementIterator, - ) -> GhosttyResult; - pub fn ghostty_kitty_graphics_placement_iterator_free( - iterator: GhosttyKittyGraphicsPlacementIterator, - ); - pub fn ghostty_kitty_graphics_placement_next( - iterator: GhosttyKittyGraphicsPlacementIterator, - ) -> bool; - pub fn ghostty_kitty_graphics_placement_get( - iterator: GhosttyKittyGraphicsPlacementIterator, - data: GhosttyKittyGraphicsPlacementData, - out: *mut ::std::os::raw::c_void, - ) -> GhosttyResult; - pub fn ghostty_kitty_graphics_placement_render_info( - iterator: GhosttyKittyGraphicsPlacementIterator, - image: GhosttyKittyGraphicsImage, - terminal: GhosttyTerminal_ptr, - out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo, - ) -> GhosttyResult; + #[doc = " Measures the terminal display width of the first grapheme cluster in a\n sequence of Unicode codepoints.\n\n This uses the exact same grapheme segmentation and cluster width rules\n the terminal itself uses when printing text with grapheme clustering\n enabled (mode 2027), so callers can predict column layout (e.g. IME\n preedit overlays) that exactly matches what the terminal will do when\n the text is actually written to it. Unlike\n ghostty_unicode_codepoint_width(), this accounts for cluster-level\n rules: emoji variation selectors, ZWJ sequences, combining marks, and\n skin tone modifiers.\n\n Reads codepoints from cps until the terminal would consider the\n grapheme cluster complete, stores the cluster's total width in cells\n (0, 1, or 2) into width (which may be NULL if only segmentation is\n desired), and returns the number of codepoints consumed. Returns 0 if\n and only if len is 0; otherwise consumes at least one codepoint. Measure\n a whole string by calling in a loop:\n\n @code\n size_t total = 0;\n for (size_t i = 0; i < len;) {\n uint8_t width;\n i += ghostty_unicode_grapheme_width(cps + i, len - i, &width);\n total += width;\n }\n @endcode\n\n This is not a streaming API. The provided sequence must contain a\n complete first grapheme cluster, or the logical end of the string. If\n input arrives in chunks, keep buffering while this function consumes all\n available codepoints (return value == len) and the stream may still\n continue; a later codepoint could still extend the cluster and change\n its width.\n\n Width semantics, matching the terminal with mode 2027 enabled:\n - The cluster starts at the width of its first codepoint, as returned by\n ghostty_unicode_codepoint_width().\n - VS16 (U+FE0F) forces the cluster wide (2) and VS15 (U+FE0E) forces it\n narrow (1), but only when the immediately preceding codepoint in the\n cluster is a valid emoji variation sequence base (per Unicode\n emoji-variation-sequences.txt). Invalid variation selectors are\n ignored entirely.\n - Any other continuation codepoint that contributes to grapheme width\n forces the cluster wide (2). Note this means cluster width is NOT the\n maximum of per-codepoint widths: some continuation marks have narrow\n codepoint width yet still widen the cluster.\n\n Mode dependence: this models mode 2027 (grapheme clustering) enabled,\n which is Ghostty's recommended configuration. When mode 2027 is\n disabled, clusters never combine and variation selectors never change\n width; predict layout in that case by summing\n ghostty_unicode_codepoint_width() over each codepoint instead.\n\n Edge cases:\n - Codepoints beyond U+10FFFF consume one codepoint, have width 1, and\n are always cluster boundaries. This function is total; it never fails.\n - Control characters (C0/C1, CR, LF) are never printed through the\n terminal's text path; passing them here returns an unspecified (but\n stable and bounded) result.\n - A cluster whose first codepoint is zero-width (e.g. a lone combining\n mark) is malformed at a cell start; the terminal may attach it to\n earlier screen content. This function reports the fold result for the\n sequence in isolation (typically 0).\n\n This function is pure, allocates nothing, and is thread-safe.\n\n @param cps Pointer to codepoints (may be NULL only when len is 0)\n @param len Number of codepoints available\n @param width Out: cluster display width in cells (0-2); may be NULL\n @return Number of codepoints in the first grapheme cluster"] + pub fn ghostty_unicode_grapheme_width(cps: *const u32, len: usize, width: *mut u8) -> usize; } diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index a65272c6..d2213c37 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -217,9 +217,7 @@ pub struct KittyImageDescriptor { #[derive(Debug, Clone, Copy)] struct KittyImageFingerprintEntry { - transmit_time_ns: u64, - data_ptr: usize, - data_len: usize, + generation: u64, fingerprint: u64, } @@ -449,7 +447,7 @@ struct WritePtyCallbackState { } unsafe extern "C" fn write_pty_trampoline( - _terminal: ffi::GhosttyTerminal_ptr, + _terminal: ffi::GhosttyTerminal, userdata: *mut c_void, data: *const u8, len: usize, @@ -583,7 +581,7 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> { } pub struct Terminal { - raw: ffi::GhosttyTerminal_ptr, + raw: ffi::GhosttyTerminal, write_pty_callback: Option<Box<WritePtyCallbackState>>, kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>, } @@ -600,11 +598,22 @@ impl Terminal { unsafe { ffi::ghostty_terminal_new(ptr::null(), &mut raw, options).into_result()?; } - Ok(Self { + + let terminal = Self { raw, write_pty_callback: None, kitty_fingerprints: Mutex::new(HashMap::new()), - }) + }; + let glyph_protocol = false; + unsafe { + 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]) { @@ -711,10 +720,6 @@ impl Terminal { unsafe { ffi::ghostty_terminal_mode_set(self.raw, mode, value).into_result() } } - pub fn enable_grapheme_cluster_mode(&mut self) -> Result<(), Error> { - self.mode_set(MODE_GRAPHEME_CLUSTER, true) - } - pub fn kitty_keyboard_flags(&self) -> Result<u8, Error> { let mut out = 0u8; unsafe { @@ -921,7 +926,7 @@ impl Terminal { } fn format_keyboard_state_ansi(&self, kitty_keyboard: bool) -> Result<String, Error> { - let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut(); + let mut formatter: ffi::GhosttyFormatter = ptr::null_mut(); let options = ffi::GhosttyFormatterTerminalOptions { size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(), emit: FormatterFormat::Vt.as_raw(), @@ -998,7 +1003,7 @@ impl Terminal { end: end_ref, rectangle, }; - let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut(); + let mut formatter: ffi::GhosttyFormatter = ptr::null_mut(); let options = ffi::GhosttyFormatterTerminalOptions { size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(), emit: format.as_raw(), @@ -1200,7 +1205,7 @@ impl Terminal { } /// Fingerprint for `image`, cached per image id and recomputed only when - /// the image's transmit time (or data identity) changes. + /// the image's generation changes. fn kitty_image_fingerprint_cached( &self, image: ffi::GhosttyKittyGraphicsImage, @@ -1211,19 +1216,16 @@ impl Terminal { format: KittyImageFormat, ) -> u64 { let (data_ptr, data_len) = data; - let Ok(transmit_time_ns) = kitty_image_u64( + let Ok(generation) = kitty_image_u64( image, - ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS, + 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.transmit_time_ns == transmit_time_ns - && entry.data_ptr == data_ptr as usize - && entry.data_len == data_len - { + if entry.generation == generation { return entry.fingerprint; } } @@ -1235,9 +1237,7 @@ impl Terminal { cache.insert( image_id, KittyImageFingerprintEntry { - transmit_time_ns, - data_ptr: data_ptr as usize, - data_len, + generation, fingerprint, }, ); @@ -1499,7 +1499,7 @@ impl Terminal { Ok(placements) } - fn raw(&self) -> ffi::GhosttyTerminal_ptr { + fn raw(&self) -> ffi::GhosttyTerminal { self.raw } } @@ -2024,7 +2024,7 @@ fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<Strin } pub struct RenderState { - raw: ffi::GhosttyRenderState_ptr, + raw: ffi::GhosttyRenderState, } impl RenderState { @@ -2140,7 +2140,7 @@ impl RenderState { ffi::ghostty_render_state_get( self.raw, ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, - (&mut iterator.raw as *mut ffi::GhosttyRenderStateRowIterator_ptr).cast(), + (&mut iterator.raw as *mut ffi::GhosttyRenderStateRowIterator).cast(), ) .into_result()?; } @@ -2183,7 +2183,7 @@ impl Drop for RenderState { } pub struct KeyEvent { - raw: ffi::GhosttyKeyEvent_ptr, + raw: ffi::GhosttyKeyEvent, } impl KeyEvent { @@ -2223,7 +2223,7 @@ impl Drop for KeyEvent { } pub struct KeyEncoder { - raw: ffi::GhosttyKeyEncoder_ptr, + raw: ffi::GhosttyKeyEncoder, } impl KeyEncoder { @@ -2254,7 +2254,7 @@ impl Drop for KeyEncoder { } pub struct MouseEvent { - raw: ffi::GhosttyMouseEvent_ptr, + raw: ffi::GhosttyMouseEvent, } impl MouseEvent { @@ -2294,7 +2294,7 @@ impl Drop for MouseEvent { } pub struct MouseEncoder { - raw: ffi::GhosttyMouseEncoder_ptr, + raw: ffi::GhosttyMouseEncoder, } impl MouseEncoder { @@ -2379,7 +2379,7 @@ fn encode_with_retry( } pub struct RowIterator { - raw: ffi::GhosttyRenderStateRowIterator_ptr, + raw: ffi::GhosttyRenderStateRowIterator, } impl RowIterator { @@ -2512,7 +2512,7 @@ impl<'a> RowIter<'a> { ffi::ghostty_render_state_row_get( self.iterator.raw, ffi::GhosttyRenderStateRowData_GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, - (&mut cells.raw as *mut ffi::GhosttyRenderStateRowCells_ptr).cast(), + (&mut cells.raw as *mut ffi::GhosttyRenderStateRowCells).cast(), ) .into_result()?; } @@ -2521,7 +2521,7 @@ impl<'a> RowIter<'a> { } pub struct RowCells { - raw: ffi::GhosttyRenderStateRowCells_ptr, + raw: ffi::GhosttyRenderStateRowCells, } impl RowCells { @@ -2799,51 +2799,63 @@ impl<'a> RowCellIter<'a> { } pub fn grapheme_text(&self) -> Result<String, Error> { - let mut codepoints = Vec::new(); + let mut bytes = Vec::new(); let mut text = String::new(); - self.grapheme_text_into(&mut codepoints, &mut text)?; + self.grapheme_text_into(&mut bytes, &mut text)?; Ok(text) } - pub fn grapheme_text_into( - &self, - codepoints: &mut Vec<u32>, - text: &mut String, - ) -> Result<(), Error> { + pub fn grapheme_text_into(&self, bytes: &mut Vec<u8>, text: &mut String) -> Result<(), Error> { text.clear(); - codepoints.clear(); + bytes.clear(); - let mut len = 0u32; - unsafe { + 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_LEN, - (&mut len as *mut u32).cast(), + ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8, + (&mut buffer as *mut ffi::GhosttyBuffer).cast(), ) - .into_result()?; }; + 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 len == 0 { + if buffer.len == 0 { return self.raw_cell_text_into(text); } - - // Avoid GRAPHEMES_UTF8 here. In libghostty-vt 0.6.7's vendor, its - // required-byte query can underreport multi-codepoint graphemes. - codepoints.resize(len as usize, 0); + 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_BUF, - codepoints.as_mut_ptr().cast(), + ffi::GhosttyRenderStateRowCellsData_GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8, + (&mut buffer as *mut ffi::GhosttyBuffer).cast(), ) .into_result()?; } - - for codepoint in codepoints.iter().copied() { - match char::from_u32(codepoint) { - Some(ch) => text.push(ch), - None => text.push(char::REPLACEMENT_CHARACTER), - } + 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(()) } @@ -2864,6 +2876,29 @@ mod tests { 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 { @@ -2907,6 +2942,14 @@ mod tests { .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); // Same id and size, different pixels. terminal.write(b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;AAAAAA==\x1b\\"); @@ -2915,12 +2958,30 @@ mod tests { .unwrap(); assert_eq!(second.len(), 1); assert_ne!(first[0].data_fingerprint, second[0].data_fingerprint); - - // No retransmission, so the fingerprint stays stable across renders. + 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] @@ -3166,6 +3227,44 @@ mod tests { } #[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 active_screen_and_cursor_visibility_contract() { let mut terminal = Terminal::new(12, 3, 0).unwrap(); let mut render_state = RenderState::new().unwrap(); @@ -3251,56 +3350,13 @@ mod tests { } #[test] - fn render_cells_handle_issue_453_unicode_payload() { - let mut terminal = Terminal::new(80, 3, 100).unwrap(); - terminal.write("README 👨👩👧👦 🧑💻 ✅ ⚡ 漢字 café é 🏳️🌈 🚀\r\n".as_bytes()); - - 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 codepoints = Vec::new(); - let mut text = String::new(); - - while rows.next() { - let mut cells = rows.populate_cells(&mut row_cells).unwrap(); - while cells.next() { - cells - .grapheme_text_into(&mut codepoints, &mut text) - .unwrap(); - } - } - } - - #[test] - fn grapheme_cluster_mode_keeps_issue_453_payload_safe() { + fn render_cells_preserve_issue_453_unicode_payload_exactly() { + const PAYLOAD: &str = "README 👨👩👧👦 🧑💻 ✅ ⚡ 漢字 café é 🏳️🌈 🚀"; let mut terminal = Terminal::new(80, 3, 100).unwrap(); - terminal.enable_grapheme_cluster_mode().unwrap(); - terminal.write("README 👨👩👧👦 🧑💻 ✅ ⚡ 漢字 café é 🏳️🌈 🚀\r\n".as_bytes()); - - 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 codepoints = Vec::new(); - let mut text = String::new(); + assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap()); + terminal.write(format!("{PAYLOAD}\r\n").as_bytes()); - while rows.next() { - let mut cells = rows.populate_cells(&mut row_cells).unwrap(); - while cells.next() { - cells - .grapheme_text_into(&mut codepoints, &mut text) - .unwrap(); - } - } + assert_eq!(first_rendered_row_text(&terminal), PAYLOAD); } #[test] diff --git a/src/pane.rs b/src/pane.rs index 3c817876..59dee7ef 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1716,9 +1716,6 @@ impl PaneRuntime { let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; - terminal - .enable_grapheme_cluster_mode() - .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() @@ -1852,9 +1849,6 @@ impl PaneRuntime { let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; - terminal - .enable_grapheme_cluster_mode() - .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 838515e0..f7cf9eb9 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -144,10 +144,9 @@ impl DefaultColorOscTracker { } fn is_default_color_set_osc(body: &[u8]) -> bool { - matches!( - parse_default_color_event(body), - Some(DefaultColorEvent::Set(_)) - ) + parse_default_color_events(body) + .iter() + .any(|event| matches!(event, DefaultColorEvent::Set(_))) } #[derive(Debug, Default)] @@ -233,27 +232,43 @@ impl DefaultColorEventTracker { } fn finalize(&mut self, end_offset: usize) { - if let Some(event) = parse_default_color_event(&self.body) { - self.pending - .push(DefaultColorTrackedEvent { end_offset, event }); - } + self.pending.extend( + parse_default_color_events(&self.body) + .into_iter() + .map(|event| DefaultColorTrackedEvent { end_offset, event }), + ); self.body.clear(); } + pub(super) fn in_progress_event(&self) -> Option<DefaultColorEvent> { + if !matches!( + self.state, + DefaultColorOscTrackerState::OscBody | DefaultColorOscTrackerState::OscEscape + ) { + return None; + } + let mut events = parse_default_color_events(&self.body); + (events.len() == 1).then(|| events.remove(0)) + } + pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorTrackedEvent> { std::mem::take(&mut self.pending) } } -fn parse_default_color_event(body: &[u8]) -> Option<DefaultColorEvent> { - match body { +fn parse_default_color_events(body: &[u8]) -> Vec<DefaultColorEvent> { + let single = match body { b"10;?" => Some(DefaultColorEvent::Query(DefaultColorQuery::Foreground)), b"11;?" => Some(DefaultColorEvent::Query(DefaultColorQuery::Background)), b"12;?" => Some(DefaultColorEvent::Query(DefaultColorQuery::Cursor)), b"110" | b"110;" => Some(DefaultColorEvent::Reset(DefaultColorQuery::Foreground)), b"111" | b"111;" => Some(DefaultColorEvent::Reset(DefaultColorQuery::Background)), - _ => parse_palette_color_query(body).or_else(|| parse_default_color_set_event(body)), + _ => parse_palette_color_query(body), + }; + if let Some(event) = single { + return vec![event]; } + parse_default_color_set_events(body) } fn parse_palette_color_query(body: &[u8]) -> Option<DefaultColorEvent> { @@ -270,15 +285,33 @@ fn parse_palette_color_query(body: &[u8]) -> Option<DefaultColorEvent> { .map(DefaultColorEvent::PaletteQuery) } -fn parse_default_color_set_event(body: &[u8]) -> Option<DefaultColorEvent> { - let separator = body.iter().position(|byte| *byte == b';')?; - let query = match &body[..separator] { - b"10" => DefaultColorQuery::Foreground, - b"11" => DefaultColorQuery::Background, - _ => return None, +fn parse_default_color_set_events(body: &[u8]) -> Vec<DefaultColorEvent> { + let Some(separator) = body.iter().position(|byte| *byte == b';') else { + return Vec::new(); + }; + let start = match &body[..separator] { + b"10" => 10, + b"11" => 11, + b"12" => 12, + _ => return Vec::new(), }; - let value = &body[separator + 1..]; - (!value.is_empty() && value != b"?").then_some(DefaultColorEvent::Set(query)) + body[separator + 1..] + .split(|byte| *byte == b';') + .filter(|value| !value.is_empty()) + .enumerate() + .filter_map(|(offset, value)| { + if value == b"?" { + return None; + } + let query = match start + offset { + 10 => DefaultColorQuery::Foreground, + 11 => DefaultColorQuery::Background, + 12 => DefaultColorQuery::Cursor, + _ => return None, + }; + Some(DefaultColorEvent::Set(query)) + }) + .collect() } /// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies @@ -1359,6 +1392,25 @@ mod tests { } #[test] + fn default_color_event_tracker_tracks_each_multi_value_set() { + let mut tracker = DefaultColorEventTracker::default(); + + tracker.observe( + b"\x1b]10;rgb:11/22/33;rgb:44/55/66\x1b\\\x1b]10;?;rgb:77/88/99\x1b\\\x1b]10;;rgb:aa/bb/cc\x1b\\", + ); + + assert_eq!( + tracked_default_color_events(tracker.drain_pending()), + vec![ + DefaultColorEvent::Set(DefaultColorQuery::Foreground), + DefaultColorEvent::Set(DefaultColorQuery::Background), + DefaultColorEvent::Set(DefaultColorQuery::Background), + DefaultColorEvent::Set(DefaultColorQuery::Foreground), + ] + ); + } + + #[test] fn default_color_event_tracker_handles_split_default_color_queries() { let mut tracker = DefaultColorEventTracker::default(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 06cdea35..cf7a82f4 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1107,6 +1107,7 @@ impl GhosttyPaneTerminal { core.xtgettcap_query_tracker .observe(filtered_bytes.as_ref()); core.decscusr_tracker.observe(filtered_bytes.as_ref()); + let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); let xtgettcap_responses = core.xtgettcap_query_tracker.drain_pending(); let write_started = crate::render_prof::timer(); @@ -1114,6 +1115,7 @@ impl GhosttyPaneTerminal { &mut core, filtered_bytes.as_ref(), default_color_events, + in_progress_default_color_event, xtgettcap_responses, &mut terminal_responses, ); @@ -1177,6 +1179,7 @@ impl GhosttyPaneTerminal { core: &mut GhosttyPaneCore, bytes: &[u8], default_color_events: Vec<DefaultColorTrackedEvent>, + in_progress_default_color_event: Option<DefaultColorEvent>, xtgettcap_responses: Vec<XtgettcapResponse>, terminal_responses: &mut Vec<Bytes>, ) { @@ -1196,16 +1199,26 @@ impl GhosttyPaneTerminal { let mut written = 0; for event in events { let end_offset = event.end_offset().min(bytes.len()); + let mut libghostty_responses = Vec::new(); if end_offset > written { core.terminal.write(&bytes[written..end_offset]); - terminal_responses.extend(self.drain_pending_pty_responses()); + libghostty_responses = self.drain_pending_pty_responses(); written = end_offset; } match event { OrderedPtyResponseEvent::DefaultColor(event) => { - respond_to_default_color_event(core, terminal_responses, event.event); + let replacement = respond_to_default_color_event(core, event.event); + if replacement.is_some() { + remove_last_matching_libghostty_color_reply( + &mut libghostty_responses, + event.event, + ); + } + terminal_responses.extend(libghostty_responses); + terminal_responses.extend(replacement); } OrderedPtyResponseEvent::Xtgettcap(response) => { + terminal_responses.extend(libghostty_responses); terminal_responses.push(response.bytes); } } @@ -1213,7 +1226,13 @@ impl GhosttyPaneTerminal { if written < bytes.len() { core.terminal.write(&bytes[written..]); - terminal_responses.extend(self.drain_pending_pty_responses()); + let mut libghostty_responses = self.drain_pending_pty_responses(); + if let Some(event) = in_progress_default_color_event { + if default_color_event_response(core, event).is_some() { + remove_last_matching_libghostty_color_reply(&mut libghostty_responses, event); + } + } + terminal_responses.extend(libghostty_responses); } if !core.child_default_foreground_changed && !core.child_default_background_changed { @@ -1777,7 +1796,7 @@ impl GhosttyPaneTerminal { Ok(rows) => rows, Err(_) => return, }; - let mut grapheme_codepoints = Vec::new(); + let mut grapheme_bytes = Vec::new(); let mut symbol_scratch = String::new(); let mut y = 0u16; while y < area.height && rows.next() { @@ -1800,7 +1819,7 @@ impl GhosttyPaneTerminal { &cells, basic.wide, hide_kitty_placeholders, - &mut grapheme_codepoints, + &mut grapheme_bytes, &mut symbol_scratch, ) { Ok(symbol) => symbol, @@ -2031,7 +2050,7 @@ fn ghostty_collect_dirty_patch( let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else { fallback!("populate_rows_error"); }; - let mut grapheme_codepoints = Vec::new(); + let mut grapheme_bytes = Vec::new(); let mut symbol_scratch = String::new(); let mut patch_rows = Vec::new(); let mut y = 0u16; @@ -2069,7 +2088,7 @@ fn ghostty_collect_dirty_patch( &cells, basic.wide, hide_kitty_placeholders, - &mut grapheme_codepoints, + &mut grapheme_bytes, &mut symbol_scratch, ) { Ok(symbol) => symbol.to_owned(), @@ -2475,7 +2494,7 @@ fn ghostty_buffer_symbol_into<'a>( cells: &crate::ghostty::RowCellIter<'_>, wide: crate::ghostty::CellWide, hide_kitty_placeholders: bool, - grapheme_codepoints: &mut Vec<u32>, + grapheme_bytes: &mut Vec<u8>, symbol_scratch: &'a mut String, ) -> Result<&'a str, crate::ghostty::Error> { symbol_scratch.clear(); @@ -2483,7 +2502,7 @@ fn ghostty_buffer_symbol_into<'a>( crate::ghostty::CellWide::SpacerTail => {} crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '), crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => { - cells.grapheme_text_into(grapheme_codepoints, symbol_scratch)?; + cells.grapheme_text_into(grapheme_bytes, symbol_scratch)?; let hidden_kitty_placeholder = hide_kitty_placeholders && symbol_scratch.chars().next().map(u32::from) == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER); @@ -2640,30 +2659,59 @@ impl OrderedPtyResponseEvent { } } +fn remove_last_matching_libghostty_color_reply( + responses: &mut Vec<Bytes>, + event: DefaultColorEvent, +) { + if let Some(index) = responses + .iter() + .rposition(|response| is_matching_libghostty_color_reply(response, event)) + { + responses.remove(index); + } +} + +fn is_matching_libghostty_color_reply(response: &Bytes, event: DefaultColorEvent) -> bool { + let prefix = match event { + DefaultColorEvent::Query(query) => format!("\x1b]{};rgb:", query.osc_number()), + DefaultColorEvent::PaletteQuery(index) => format!("\x1b]4;{index};rgb:"), + DefaultColorEvent::Set(_) | DefaultColorEvent::Reset(_) => return false, + }; + response.starts_with(prefix.as_bytes()) + && (response.ends_with(b"\x07") || response.ends_with(b"\x1b\\")) +} + fn respond_to_default_color_event( core: &mut GhosttyPaneCore, - terminal_responses: &mut Vec<Bytes>, event: DefaultColorEvent, -) { +) -> Option<Bytes> { match event { - DefaultColorEvent::Query(query) => { - if let Some(response) = default_color_query_response(query, core) { - terminal_responses.push(response); - } + DefaultColorEvent::Query(_) | DefaultColorEvent::PaletteQuery(_) => { + default_color_event_response(core, event) } - DefaultColorEvent::PaletteQuery(index) => { - if let Some(response) = palette_color_query_response(index, core) { - terminal_responses.push(response); - } + DefaultColorEvent::Set(query) => { + mark_child_default_color_changed(core, query, true); + None } - DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true), DefaultColorEvent::Reset(query) => { mark_child_default_color_changed(core, query, false); apply_cached_host_default_color(core, query); + None } } } +fn default_color_event_response( + core: &mut GhosttyPaneCore, + event: DefaultColorEvent, +) -> Option<Bytes> { + match event { + DefaultColorEvent::Query(query) => default_color_query_response(query, core), + DefaultColorEvent::PaletteQuery(index) => palette_color_query_response(index, core), + DefaultColorEvent::Set(_) | DefaultColorEvent::Reset(_) => None, + } +} + fn default_color_query_response( query: DefaultColorQuery, core: &mut GhosttyPaneCore, @@ -4021,7 +4069,7 @@ mod tests { .populate_row_iterator(&mut row_iterator) .unwrap(); let mut row_cells = crate::ghostty::RowCells::new().unwrap(); - let mut grapheme_codepoints = Vec::new(); + let mut grapheme_bytes = Vec::new(); let mut symbol_scratch = String::new(); let mut out = Vec::new(); @@ -4033,7 +4081,7 @@ mod tests { &cells, wide, false, - &mut grapheme_codepoints, + &mut grapheme_bytes, &mut symbol_scratch, ) .unwrap() @@ -4048,7 +4096,6 @@ mod tests { #[test] fn grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell() { let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); - terminal.enable_grapheme_cluster_mode().unwrap(); terminal.write("🇧🇷".as_bytes()); let cells = render_cells_to_symbols(&mut terminal); @@ -4064,7 +4111,6 @@ mod tests { #[test] fn grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell() { let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); - terminal.enable_grapheme_cluster_mode().unwrap(); terminal.write("👨\u{200d}👩\u{200d}👧".as_bytes()); let cells = render_cells_to_symbols(&mut terminal); @@ -4188,6 +4234,27 @@ mod tests { } #[test] + fn resize_shrinks_both_axes_with_cursor_at_old_bottom() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(8, 4, 10_000).unwrap(); + terminal.write(b"alpha\r\nbeta\r\ngamma\r\ndelta"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.resize(3, 7, 8, 16); + + assert_eq!(pane.visible_text(), "beta\ngamma\ndelta\n"); + assert_eq!(pane.detection_text(), "beta\ngamma\ndelta\n"); + assert_eq!( + pane.scroll_metrics(), + Some(ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 1, + viewport_rows: 3, + }) + ); + } + + #[test] fn resize_reflow_keeps_scrolled_viewport_and_bottom_detection_sane() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(12, 4, 10_000).unwrap(); @@ -4508,6 +4575,19 @@ mod tests { } #[test] + fn process_pty_bytes_does_not_advertise_unsupported_glyph_protocol() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b_25a1;s\x1b\\", &tx); + + assert!(result.terminal_responses.is_empty()); + assert!(rx.try_recv().is_err()); + } + + #[test] fn process_pty_bytes_returns_libghostty_query_responses_without_queuing_input() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); @@ -4655,7 +4735,10 @@ mod tests { }); let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx); - assert!(result.terminal_responses.is_empty()); + assert_eq!( + result.terminal_responses, + vec![Bytes::from_static(b"\x1b]11;rgb:1111/2222/3333\x07")] + ); assert!(rx.try_recv().is_err()); } @@ -4884,7 +4967,7 @@ mod tests { } #[test] - fn process_pty_bytes_ignores_malformed_palette_color_queries() { + fn process_pty_bytes_ignores_malformed_and_preserves_multi_palette_queries() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); @@ -4897,7 +4980,15 @@ mod tests { &tx, ); - assert!(result.terminal_responses.is_empty()); + assert_eq!(result.terminal_responses.len(), 1); + assert!(result.terminal_responses[0].starts_with(b"\x1b]4;0;rgb:")); + assert_eq!( + result.terminal_responses[0] + .windows(4) + .filter(|window| *window == b"rgb:") + .count(), + 2 + ); assert!(rx.try_recv().is_err()); } @@ -4957,6 +5048,108 @@ mod tests { } #[test] + fn process_pty_bytes_preserves_untracked_multi_color_query_responses() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0x65, + g: 0x7b, + b: 0x83, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0xfd, + g: 0xf6, + b: 0xe3, + }), + }); + + let palette = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?;1;?\x1b\\", &tx); + let palette_response = palette.terminal_responses.concat(); + assert!(palette_response.starts_with(b"\x1b]4;0;rgb:")); + assert_eq!( + palette_response + .windows(4) + .filter(|window| *window == b"rgb:") + .count(), + 2 + ); + + let defaults = pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?;?;?\x1b\\", &tx); + let default_response = defaults.terminal_responses.concat(); + assert!( + default_response.starts_with(b"\x1b]10;rgb:"), + "unexpected default-color report: {:?}", + String::from_utf8_lossy(&default_response) + ); + assert_eq!( + default_response + .windows(4) + .filter(|window| *window == b"rgb:") + .count(), + 3 + ); + let core = pane.core.lock().unwrap(); + assert!(!core.child_default_foreground_changed); + assert!(!core.child_default_background_changed); + drop(core); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_preserves_earlier_aggregate_palette_reply() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let result = + pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?;1;?\x1b\\\x1b]4;0;?\x1b\\", &tx); + + assert_eq!(result.terminal_responses.len(), 2); + assert_eq!( + result.terminal_responses[0] + .windows(4) + .filter(|window| *window == b"rgb:") + .count(), + 2 + ); + assert!(result.terminal_responses[1].starts_with(b"\x1b]4;0;rgb:")); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_preserves_libghostty_reply_for_child_color_override() { + let (tx, mut rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"\x1b]10;rgb:11/22/33\x07", &tx); + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?\x1b\\", &tx); + + assert_eq!(result.terminal_responses.len(), 1); + assert!(result.terminal_responses[0].starts_with(b"\x1b]10;rgb:1111/2222/3333")); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn process_pty_bytes_tracks_later_multi_value_color_set() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?;rgb:44/55/66\x1b\\", &tx); + + let core = pane.core.lock().unwrap(); + assert!(!core.child_default_foreground_changed); + assert!(core.child_default_background_changed); + } + + #[test] fn process_pty_bytes_returns_cursor_color_query_response_from_foreground_fallback() { let (tx, mut rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); @@ -5140,7 +5333,10 @@ mod tests { let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;rgb:11/22/33\x07\x1b]11;?\x07", &tx); - assert!(result.terminal_responses.is_empty()); + assert_eq!( + result.terminal_responses, + vec![Bytes::from_static(b"\x1b]11;rgb:1111/2222/3333\x07")] + ); assert!(rx.try_recv().is_err()); let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07\x1b]11;?\x07", &tx); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index cd44ccf7..bd4d8382 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -1582,11 +1582,10 @@ mod tests { .next() .expect("restored runtime should exist"); + let restored_text = runtime.recent_unwrapped_text(10); assert!( - runtime - .recent_unwrapped_text(10) - .contains("RESTORED_HISTORY"), - "saved history should be visible in the restored terminal backend" + restored_text.contains("RESTORED_HISTORY 👨👩👧 LINK"), + "styled Unicode and hyperlink text should survive history replay" ); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -1655,7 +1654,11 @@ mod tests { panes: HashMap::from([( 0, super::super::snapshot::PaneHistorySnapshot { - ansi: "RESTORED_HISTORY\r\n".to_string(), + ansi: concat!( + "\x1b[31mRESTORED_HISTORY 👨👩👧\x1b[0m ", + "\x1b]8;;https://example.com\x1b\\LINK\x1b]8;;\x1b\\\r\n" + ) + .to_string(), lines: 1, }, )]), diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 94bb998b..c01fa9b6 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -2,81 +2,39 @@ This file tracks intentional local changes applied on top of the vendored `libghostty-vt` source. Remove a patch only when the vendored source commit -contains the upstream fix and the listed verification still passes. +contains the upstream behavior and the listed verification still passes. -## 0001 backport resizeCols cursor subtraction saturation +## 0001 default lib-vt panes to grapheme clustering status: active -patch: `vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch` +patch: `vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch` -herdr issue: https://github.com/ogulcancelik/herdr/issues/465 +herdr issue: https://github.com/ogulcancelik/herdr/issues/243 -upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905 +upstream discussion: not opened; libghostty-vt currently exposes current mode mutation but no C API for configuring terminal default modes -upstream pr: https://github.com/ghostty-org/ghostty/pull/12907 +upstream pr: not opened -introduced upstream: `c44afa625` - -vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8` +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` local files: -- `vendor/libghostty-vt/src/terminal/PageList.zig` - `vendor/libghostty-vt/src/terminal/c/terminal.zig` -reason: shrinking rows and columns in one resize can leave the pre-resize -cursor row past the new row count. `PageList.resizeCols` then computed rows -below the cursor with checked unsigned subtraction and aborted in safety builds. - -remove when: the vendored source commit contains upstream PR #12907 and the -local ReleaseSafe resize regression tests pass without this patch. - -verification: - -```sh -zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="resize shrinks both axes with cursor at bottom" -zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="PageList resize less rows and cols cursor at bottom" -``` - -## 0002 expose kitty image transmit time in the C API - -status: active - -patch: `vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch` - -herdr issue: https://github.com/ogulcancelik/herdr/issues/947 - -upstream discussion: https://github.com/ghostty-org/ghostty/discussions/13177 -(proposes extending the kitty graphics inspection C API from -https://github.com/ghostty-org/ghostty/pull/12145, which has no transmit -time/serial accessor yet) - -introduced upstream: not yet - -vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8` - -local files: - -- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h` -- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig` - -reason: herdr fingerprints kitty image data to decide when to re-encode an -image for render clients. Hashing the full payload on every render is too -expensive for multi-megabyte images, and sampling windows misses small -changes, freezing streaming sources. The image's transmit time already -refreshes on every (re)transmission, so exposing it as -`GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS` gives herdr an exact, O(1) change -serial to invalidate a cached full-data fingerprint. +reason: Herdr renders terminal cells directly and requires DEC private mode +2027 to store flags, ZWJ emoji, and other multi-codepoint grapheme clusters in +one cell. This patch makes clustering active for new terminals and keeps it as +the reset default so RIS (`ESC c`) does not disable it. -remove when: the vendored source commit exposes the image transmit time (or an -equivalent transmission serial) in the C API and -`ghostty::tests::kitty_image_fingerprint_refreshes_on_retransmission` passes -without this patch. +remove when: libghostty-vt exposes a C API for setting default mode 2027, or +upstream makes grapheme clustering the lib-vt default, and the reset-survival +regression passes without this patch. verification: ```sh -zig build test-lib-vt -Dtest-filter="image_get transmit_time_ns changes on retransmission" -cargo nextest run kitty_image_fingerprint +cargo nextest run --locked grapheme_cluster_mode_is_default_and_survives_full_reset +cargo nextest run --locked grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell +cargo nextest run --locked grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell ``` diff --git a/vendor/libghostty-vt.vendor.json b/vendor/libghostty-vt.vendor.json index 4d68f2c4..3dd123b2 100644 --- a/vendor/libghostty-vt.vendor.json +++ b/vendor/libghostty-vt.vendor.json @@ -1,6 +1,5 @@ { - "source_repo": "/home/can/Projects/ghostty-worktrees/herdr-vendor-0f7cd84b", - "source_commit": "0f7cd84b880b203c98683e520e84b9db0c5938d8", - "dist_archive": "libghostty-vt-1.3.2-HEAD-+0f7cd84b8.tar.gz", - "extracted_dir": "libghostty-vt-1.3.2-HEAD-+0f7cd84b8" + "source_commit": "c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3", + "dist_archive": "libghostty-vt-1.3.2-HEAD-+c5a21edfc.tar.gz", + "extracted_dir": "libghostty-vt-1.3.2-HEAD-+c5a21edfc" } diff --git a/vendor/libghostty-vt/.github/VOUCHED.td b/vendor/libghostty-vt/.github/VOUCHED.td index 1749611a..e6d365c8 100644 --- a/vendor/libghostty-vt/.github/VOUCHED.td +++ b/vendor/libghostty-vt/.github/VOUCHED.td @@ -33,10 +33,12 @@ adrum agoodkind aindriu80 ajiblock +ajr-khll akimiojr alaasdk alanmoyano alaviss +alexbathome alexfeijoo44 alexjuca alosarjos @@ -45,8 +47,10 @@ andrejdaskalov anhthang anmitalidev anthonyzhoon +arvin7liu athaapa atomk +aymanbagabas b0uks b1nar10 balazs-szucs @@ -62,6 +66,7 @@ bleikurr bo2themax brentschroeter brianc442 +c0x0o cespare charliie-dev chernetskyi @@ -100,6 +105,9 @@ ephemera -eric-assetpass Try talking, not botting eriksremess erral +escalonc +eunos-1128 +exlee -f1813483-netizen faukah filip7 @@ -128,6 +136,7 @@ illiakrauchanka j0hnm4r5 jacobsandlund jake-stewart +jamesarch jamylak jarred-sumner jcollie @@ -154,6 +163,7 @@ kloneets knu -kody-w koranir +kostich kristina8888 kristofersoler kylesower @@ -195,12 +205,15 @@ mpatankar6 mrconnorkenway mrmage mtak +mustafa0x +nagato-yuzuru natesmyth neo773 neurosnap nicholas-ochoa nicosuave nikicat +nilsherzig nmggithub noib3 nolinmcfarland @@ -213,6 +226,7 @@ paaloeye pan93412 pangoraw pauley-unsaturated +pearkes peilingjiang peterdavehello philocalyst @@ -224,6 +238,8 @@ poweruser64 prakhar54-byte priyans-hu puzza007 +qappell +quinnypig qwerasd205 raphamorim reo101 @@ -236,14 +252,18 @@ rmengelbrecht rmunn rockorager rpfaeffle +ruoyouz +sahilkmishra samasaur1 sandydoo +-schickling-assistant NHITL secrus seruman seyoungjeong silveirapf slsrepo sunshine-syz +tasselx tbrundige tdgroot tdslot @@ -260,11 +280,14 @@ unphased uzaaft vancluever vaughanandrews +-vectorpeak Stupid bot cosplaying as a maintainer viruslobster vlsi voidnv +williamhcarter wyounas yabbal +yak3d yamshta ydah -zaviro diff --git a/vendor/libghostty-vt/.github/workflows/nix.yml b/vendor/libghostty-vt/.github/workflows/nix.yml index 4d5ecba4..0a3ebdc5 100644 --- a/vendor/libghostty-vt/.github/workflows/nix.yml +++ b/vendor/libghostty-vt/.github/workflows/nix.yml @@ -39,15 +39,15 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/.github/workflows/release-tag.yml b/vendor/libghostty-vt/.github/workflows/release-tag.yml index eec81248..3fd92990 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tag.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -80,16 +80,16 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable @@ -134,10 +134,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -319,7 +319,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download macOS Artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/vendor/libghostty-vt/.github/workflows/release-tip.yml b/vendor/libghostty-vt/.github/workflows/release-tip.yml index 37443cb0..721057a1 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tip.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tip.yml @@ -33,16 +33,16 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -58,16 +58,60 @@ jobs: echo "build=$GHOSTTY_BUILD" >> $GITHUB_OUTPUT echo "commit=$GHOSTTY_COMMIT" >> $GITHUB_OUTPUT echo "commit_long=$GHOSTTY_COMMIT_LONG" >> $GITHUB_OUTPUT - - name: Check if tip already exists + # Classify what changed since the last tip. `build` matches any file that + # can reach the built artifact; `release_tip` re-adds release-tip.yml, + # which defines the build/tag/publish jobs. Everything else (all of + # .github plus docs and repo/lint/editor metadata) never changes the + # artifact. Skipped on manual dispatches, which always build. + - name: Detect changes since last tip + id: changes + if: github.event_name == 'workflow_run' + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + with: + token: "" + base: tip + predicate-quantifier: "every" + filters: | + build: + - '**' + - '!.github/**' + - '!README.md' + - '!CONTRIBUTING.md' + - '!HACKING.md' + - '!PACKAGING.md' + - '!AI_POLICY.md' + - '!AGENTS.md' + - '!CLAUDE.md' + - '!Doxyfile' + - '!DoxygenLayout.xml' + - '!LICENSE' + - '!CODEOWNERS' + - '!.clang-format' + - '!.editorconfig' + - '!.envrc' + - '!.gitattributes' + - '!.gitignore' + - '!.mailmap' + - '!.prettierignore' + - '!.shellcheckrc' + - '!.swiftlint.yml' + - '!typos.toml' + - '!valgrind.supp' + release_tip: + - '.github/workflows/release-tip.yml' + + - name: Determine skip id: check run: | - GHOSTTY_COMMIT_LONG=$(git rev-parse HEAD) - if nix develop -c nu .github/scripts/ghostty-tip $GHOSTTY_COMMIT_LONG; then - echo "Tip release already exists for commit $GHOSTTY_COMMIT_LONG" - echo "should_skip=true" >> $GITHUB_OUTPUT + if nix develop -c nu .github/scripts/ghostty-tip "$(git rev-parse HEAD)"; then + echo "Tip already built for this commit, skipping" + echo "should_skip=true" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.changes.outputs.build }}" = "false" ] && + [ "${{ steps.changes.outputs.release_tip }}" = "false" ]; then + echo "Only non-artifact files changed since the last tip, skipping" + echo "should_skip=true" >> "$GITHUB_OUTPUT" else - echo "No tip release found for commit $GHOSTTY_COMMIT_LONG" - echo "should_skip=false" >> $GITHUB_OUTPUT + echo "should_skip=false" >> "$GITHUB_OUTPUT" fi tag: @@ -75,7 +119,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -90,7 +134,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -113,7 +157,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -136,7 +180,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -168,14 +212,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -195,7 +239,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -223,14 +267,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -250,7 +294,7 @@ jobs: nix develop -c minisign -S -m libghostty-vt-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -298,10 +342,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -335,7 +379,7 @@ jobs: nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -387,13 +431,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -537,7 +581,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -643,13 +687,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -786,7 +830,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -840,13 +884,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -983,7 +1027,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/vendor/libghostty-vt/.github/workflows/snap.yml b/vendor/libghostty-vt/.github/workflows/snap.yml index f908110c..3108d583 100644 --- a/vendor/libghostty-vt/.github/workflows/snap.yml +++ b/vendor/libghostty-vt/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix diff --git a/vendor/libghostty-vt/.github/workflows/test.yml b/vendor/libghostty-vt/.github/workflows/test.yml index 421abfce..05787c18 100644 --- a/vendor/libghostty-vt/.github/workflows/test.yml +++ b/vendor/libghostty-vt/.github/workflows/test.yml @@ -30,8 +30,8 @@ jobs: zig: ${{ steps.filter_any.outputs.zig }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: filter_every with: token: "" @@ -40,7 +40,7 @@ jobs: code: - '**' - '!.github/VOUCHED.td' - - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: filter_any with: token: "" @@ -156,17 +156,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -187,7 +187,7 @@ jobs: swift: ${{ steps.list.outputs.swift }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - id: list name: List example directories run: | @@ -214,17 +214,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -250,17 +250,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -290,7 +290,7 @@ jobs: needs: [test, list-examples] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -315,10 +315,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -356,17 +356,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -394,17 +394,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -511,17 +511,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -545,17 +545,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -590,17 +590,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -625,10 +625,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -666,17 +666,17 @@ jobs: ANDROID_NDK_VERSION: r29 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -706,7 +706,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -723,7 +723,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -743,17 +743,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -772,17 +772,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -805,17 +805,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -887,17 +887,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -927,17 +927,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -967,7 +967,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Trigger Snap workflow run: | @@ -985,7 +985,7 @@ jobs: needs: [build-dist, build-flatpak] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Trigger Flatpak workflow run: | @@ -1002,10 +1002,10 @@ jobs: # needs: test # steps: # - name: Checkout code - # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # # Install Nix and use that to run our tests so our environment matches exactly. - # - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + # - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 # with: # nix_path: nixpkgs=channel:nixos-unstable # - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1057,10 +1057,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1117,10 +1117,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1167,7 +1167,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get required Zig version id: zig @@ -1175,14 +1175,14 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1209,17 +1209,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1244,17 +1244,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1292,17 +1292,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1327,17 +1327,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1357,10 +1357,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1396,7 +1396,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -1417,17 +1417,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1448,17 +1448,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1493,14 +1493,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1524,14 +1524,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1554,14 +1554,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1579,10 +1579,10 @@ jobs: needs: skip timeout-minutes: 60 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1612,14 +1612,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1640,14 +1640,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1668,14 +1668,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1701,14 +1701,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1729,14 +1729,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1763,17 +1763,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1808,7 +1808,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: dist file: dist/src/build/docker/debian/Dockerfile @@ -1825,17 +1825,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1864,7 +1864,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # # - name: Start SSH # run: | diff --git a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml index 916575a8..911768e5 100644 --- a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml +++ b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml @@ -17,19 +17,19 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml b/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml index c91de8ae..fb36bf88 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml @@ -14,11 +14,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: .github/issue-unvouched-message - - uses: mitchellh/vouch/action/check-issue@52aec3d64655edf2fdb58f298e02da754a056daf # unreleased main + - uses: mitchellh/vouch/action/check-issue@d66fa29a64600490892131ad87597c30c91fcac4 # unreleased main with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml b/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml index 4cc7906a..ec81e183 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml index 172c7cde..ec46fa26 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml @@ -18,11 +18,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/manage-by-discussion@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml index e78de926..631adcea 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml @@ -18,11 +18,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml b/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml index d9b7ce74..b031f29f 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml @@ -19,11 +19,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/sync-codeowners@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: repo: ${{ github.repository }} pull-request: "true" diff --git a/vendor/libghostty-vt/VERSION b/vendor/libghostty-vt/VERSION index 23f93e75..0b50d7c6 100644 --- a/vendor/libghostty-vt/VERSION +++ b/vendor/libghostty-vt/VERSION @@ -1 +1 @@ -1.3.2-HEAD-+0f7cd84b8
\ No newline at end of file +1.3.2-HEAD-+c5a21edfc
\ No newline at end of file diff --git a/vendor/libghostty-vt/build.zig.zon b/vendor/libghostty-vt/build.zig.zon index 413c30a2..7925f0d5 100644 --- a/vendor/libghostty-vt/build.zig.zon +++ b/vendor/libghostty-vt/build.zig.zon @@ -116,8 +116,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - .hash = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz", + .hash = "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD", .lazy = true, }, }, diff --git a/vendor/libghostty-vt/build.zig.zon.json b/vendor/libghostty-vt/build.zig.zon.json index ed745b36..63d5fd61 100644 --- a/vendor/libghostty-vt/build.zig.zon.json +++ b/vendor/libghostty-vt/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM": { + "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - "hash": "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz", + "hash": "sha256-quMCPsbVIeLqzIV2okjRDZTKxA6YdkUSYuxmBHf3xMg=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/vendor/libghostty-vt/build.zig.zon.nix b/vendor/libghostty-vt/build.zig.zon.nix index 1ca2810b..2b05dd1b 100644 --- a/vendor/libghostty-vt/build.zig.zon.nix +++ b/vendor/libghostty-vt/build.zig.zon.nix @@ -193,11 +193,11 @@ in }; } { - name = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM"; + name = "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz"; - hash = "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz"; + hash = "sha256-quMCPsbVIeLqzIV2okjRDZTKxA6YdkUSYuxmBHf3xMg="; unpack = false; }; } diff --git a/vendor/libghostty-vt/build.zig.zon.txt b/vendor/libghostty-vt/build.zig.zon.txt index 108afb31..02da0630 100644 --- a/vendor/libghostty-vt/build.zig.zon.txt +++ b/vendor/libghostty-vt/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/vendor/libghostty-vt/include/ghostty.h b/vendor/libghostty-vt/include/ghostty.h index fbfe3ee2..72bbb57a 100644 --- a/vendor/libghostty-vt/include/ghostty.h +++ b/vendor/libghostty-vt/include/ghostty.h @@ -934,6 +934,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, diff --git a/vendor/libghostty-vt/include/ghostty/vt.h b/vendor/libghostty-vt/include/ghostty/vt.h index 94a85033..5606f169 100644 --- a/vendor/libghostty-vt/include/ghostty/vt.h +++ b/vendor/libghostty-vt/include/ghostty/vt.h @@ -34,6 +34,7 @@ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety + * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions @@ -55,6 +56,7 @@ * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example + * - @ref c-vt-compression/src/main.c - Idle scrollback compression example * */ @@ -104,6 +106,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-compression/src/main.c + * This example demonstrates how to schedule incremental scrollback compression + * after compression-relevant terminal activity becomes idle. + */ + /** @example c-vt-selection-gesture/src/main.c * This example demonstrates how to use synthetic selection gesture events to * derive drag and deep-press selection snapshots. @@ -125,6 +132,7 @@ extern "C" { #include <ghostty/vt/allocator.h> #include <ghostty/vt/build_info.h> #include <ghostty/vt/color.h> +#include <ghostty/vt/color_scheme.h> #include <ghostty/vt/device.h> #include <ghostty/vt/focus.h> #include <ghostty/vt/formatter.h> @@ -145,6 +153,7 @@ extern "C" { #include <ghostty/vt/screen.h> #include <ghostty/vt/selection.h> #include <ghostty/vt/size_report.h> +#include <ghostty/vt/unicode.h> #include <ghostty/vt/wasm.h> #ifdef __cplusplus diff --git a/vendor/libghostty-vt/include/ghostty/vt/color.h b/vendor/libghostty-vt/include/ghostty/vt/color.h index 9dc21864..f5f7f9c4 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/color.h +++ b/vendor/libghostty-vt/include/ghostty/vt/color.h @@ -7,6 +7,114 @@ #ifndef GHOSTTY_VT_COLOR_H #define GHOSTTY_VT_COLOR_H +/** @defgroup color Color Utilities + * + * Color parsing, palette generation, color math, and X11 color name + * utilities shared by libghostty-vt. + * + * These APIs expose Ghostty's color semantics directly to embedders. Use + * them when an application needs to parse the same color strings as Ghostty + * config and theme files, generate the same 256-color palette used by the + * terminal, list supported X11 color names, or make UI decisions from + * luminance and contrast values. + * + * ## Parsing Colors + * + * ghostty_color_parse() accepts the flexible syntax used by Ghostty for + * terminal colors: + * + * - X11 color names, matched ASCII case-insensitively. + * - 3- or 6-digit hex colors, with or without a leading `#`. + * - 9- or 12-digit hex colors, with a leading `#`. + * - XParseColor-style `rgb:<red>/<green>/<blue>` values. + * - XParseColor-style `rgbi:<red>/<green>/<blue>` values. + * + * Leading and trailing spaces and tabs are ignored. Use + * ghostty_color_parse_x11() when only X11 names should be accepted. + * + * @code{.c} + * GhosttyColorRgb color; + * + * if (ghostty_color_parse( + * "ForestGreen", + * sizeof("ForestGreen") - 1, + * &color) != GHOSTTY_SUCCESS) { + * // Handle invalid color input. + * } + * + * ghostty_color_parse("#abc", sizeof("#abc") - 1, &color); + * ghostty_color_parse("rgb:12/34/56", sizeof("rgb:12/34/56") - 1, &color); + * @endcode + * + * ## Palette Entries + * + * ghostty_color_parse_palette_entry() parses a single Ghostty palette + * override in `INDEX=COLOR` form. The index may be decimal or use a `0x`, + * `0o`, or `0b` prefix. The color side uses ghostty_color_parse(). + * + * @code{.c} + * GhosttyColorRgb palette[256]; + * ghostty_color_palette_default(palette); + * + * uint8_t index; + * GhosttyColorRgb rgb; + * + * if (ghostty_color_parse_palette_entry( + * "0x10=#282c34", + * sizeof("0x10=#282c34") - 1, + * &index, + * &rgb) == GHOSTTY_SUCCESS) { + * palette[index] = rgb; + * } + * @endcode + * + * ## Palette Generation + * + * ghostty_color_palette_generate() derives the 216-color cube and grayscale + * ramp from a base palette, background, and foreground. Set bits in + * GhosttyColorPaletteMask preserve specific indices from the base palette. + * The output may alias the base input. + * + * @code{.c} + * GhosttyColorRgb palette[256]; + * ghostty_color_palette_default(palette); + * + * GhosttyColorPaletteMask skip = {0}; + * GHOSTTY_COLOR_PALETTE_MASK_SET(&skip, 16); + * + * GhosttyColorRgb background = {40, 44, 52}; + * GhosttyColorRgb foreground = {220, 223, 228}; + * + * ghostty_color_palette_generate( + * palette, + * &skip, + * &background, + * &foreground, + * true, + * palette); + * @endcode + * + * ## X11 Color Names + * + * The X11 name table is static program-lifetime memory. Entries are in + * rgb.txt order and are terminated by an entry with `name == NULL`. + * ghostty_color_x11_name_count() returns the number of non-terminator + * entries. + * + * @code{.c} + * const GhosttyColorX11Entry* names = ghostty_color_x11_names(); + * size_t count = ghostty_color_x11_name_count(); + * + * for (size_t i = 0; i < count; i++) { + * // names[i].name and names[i].color are valid here. + * } + * @endcode + * + * @{ + */ + +#include <stdbool.h> +#include <stddef.h> #include <stdint.h> #include <ghostty/vt/types.h> @@ -17,7 +125,7 @@ extern "C" { /** * RGB color value. * - * @ingroup sgr + * @ingroup color */ typedef struct { uint8_t r; /**< Red component (0-255) */ @@ -28,49 +136,128 @@ typedef struct { /** * Palette color index (0-255). * - * @ingroup sgr + * @ingroup color */ typedef uint8_t GhosttyColorPaletteIndex; -/** @addtogroup sgr - * @{ +/** + * A 256-bit mask of palette indices. + * + * Index i is set iff `(bits[i >> 6] >> (i & 63)) & 1` is 1. + * The mask is typically initialized to zero and then populated with + * GHOSTTY_COLOR_PALETTE_MASK_SET(). + * + * @code{.c} + * GhosttyColorPaletteMask mask = {0}; + * GHOSTTY_COLOR_PALETTE_MASK_SET(&mask, 20); + * if (GHOSTTY_COLOR_PALETTE_MASK_IS_SET(&mask, 20)) { + * // Index 20 will be preserved. + * } + * @endcode + * + * @ingroup color */ +typedef struct { + uint64_t bits[4]; +} GhosttyColorPaletteMask; + +/** + * An entry in Ghostty's X11 color name table. + * + * @ingroup color + */ +typedef struct { + /** Null-terminated color name. NULL marks the end of the table. */ + const char* name; + /** The RGB value of the color. */ + GhosttyColorRgb color; +} GhosttyColorX11Entry; + +/** + * Return the storage word for a palette mask index. + * + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_WORD(index) ((index) >> 6) + +/** + * Return the storage bit for a palette mask index. + * + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_BIT(index) (UINT64_C(1) << ((index) & 63)) -/** Black color (0) @ingroup sgr */ +/** + * Set a palette mask index. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_SET(mask, index) \ + ((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] |= GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) + +/** + * Clear a palette mask index. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_UNSET(mask, index) \ + ((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] &= ~GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) + +/** + * Test whether a palette mask index is set. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * @return true if the palette index is set, false otherwise + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_IS_SET(mask, index) \ + (((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] & GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) != 0) + +/** Black color (0) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ +/** Red color (1) @ingroup color */ #define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ +/** Green color (2) @ingroup color */ #define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ +/** Yellow color (3) @ingroup color */ #define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ +/** Blue color (4) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ +/** Magenta color (5) @ingroup color */ #define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ +/** Cyan color (6) @ingroup color */ #define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ +/** White color (7) @ingroup color */ #define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ +/** Bright black color (8) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ +/** Bright red color (9) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ +/** Bright green color (10) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ +/** Bright yellow color (11) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ +/** Bright blue color (12) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ +/** Bright magenta color (13) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ +/** Bright cyan color (14) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ +/** Bright white color (15) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 -/** @} */ - /** * Get the RGB color components. * @@ -78,20 +265,215 @@ typedef uint8_t GhosttyColorPaletteIndex; * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments * where accessing struct fields directly is difficult. * - * @param color The RGB color value + * @param color Pointer to the RGB color value * @param r Pointer to store the red component (0-255) * @param g Pointer to store the green component (0-255) * @param b Pointer to store the blue component (0-255) * - * @ingroup sgr + * @ingroup color */ -GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color, +GHOSTTY_API void ghostty_color_rgb_get(const GhosttyColorRgb* color, uint8_t* r, uint8_t* g, uint8_t* b); +/** + * Parse an X11 color name. + * + * The color name is resolved from Ghostty's embedded rgb.txt table. + * Leading and trailing spaces and tabs are trimmed, and matching is + * ASCII case-insensitive. Hex values are not accepted by this function. + * + * @param name The color name bytes (must not be NULL) + * @param len The length of @p name in bytes + * @param[out] out The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if no color + * matches or @p name is NULL + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse_x11( + const char* name, + size_t len, + GhosttyColorRgb* out); + +/** + * Parse a flexible Ghostty color value. + * + * Accepts Ghostty's terminal color syntax: X11 color names, hex colors + * in 3-, 6-, 9-, or 12-digit form (the leading # is optional for 3- and + * 6-digit values), and rgb:<red>/<green>/<blue> or + * rgbi:<red>/<green>/<blue> specifications. Leading and trailing spaces + * and tabs are trimmed. + * + * @param value The color value bytes (must not be NULL) + * @param len The length of @p value in bytes + * @param[out] out The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if parsing + * fails or @p value is NULL + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse( + const char* value, + size_t len, + GhosttyColorRgb* out); + +/** + * Parse a Ghostty palette entry. + * + * Accepts Ghostty palette config syntax: N=COLOR. N is a palette index + * from 0 to 255 in decimal or in 0x, 0o, or 0b-prefixed form. Spaces and + * tabs around N and COLOR are ignored. COLOR accepts the same syntax as + * ghostty_color_parse(). + * + * @param value The palette entry bytes (must not be NULL) + * @param len The length of @p value in bytes + * @param[out] out_index The parsed palette index + * @param[out] out_rgb The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE on any + * failure, including index overflow + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse_palette_entry( + const char* value, + size_t len, + uint8_t* out_index, + GhosttyColorRgb* out_rgb); + +/** + * Get Ghostty's built-in default 256-color palette. + * + * Writes exactly 256 entries: Ghostty's base16 defaults, the xterm + * 6x6x6 color cube, and the grayscale ramp. + * + * @param[out] out The output palette, an array of exactly 256 + * GhosttyColorRgb values + * + * @ingroup color + */ +GHOSTTY_API void ghostty_color_palette_default(GhosttyColorRgb* out); + +/** + * Generate a 256-color palette from base colors. + * + * The base palette supplies indices 0-15, which are always preserved. + * If @p base is NULL, Ghostty's default palette is used. If @p skip is + * NULL, no extra indices are skipped. Set bits in @p skip preserve those + * indices from @p base. The 216-color cube at indices 16-231 is generated + * with trilinear CIELAB interpolation, and the grayscale ramp at indices + * 232-255 is interpolated from the background to the foreground. + * + * For light themes, @p harmonious controls whether the generated palette + * keeps the background-to-foreground orientation. When false, Ghostty + * swaps the light background and dark foreground so the cube and ramp run + * dark-to-light. The output palette may be the same pointer as @p base. + * + * @param base The base palette, an array of exactly 256 GhosttyColorRgb + * values, or NULL to use Ghostty's default palette + * @param skip The palette indices to preserve from @p base, or NULL for + * an empty mask + * @param bg The terminal background color (must not be NULL) + * @param fg The terminal foreground color (must not be NULL) + * @param harmonious Whether light themes keep background-to-foreground + * orientation + * @param[out] out The output palette, an array of exactly 256 + * GhosttyColorRgb values + * + * @ingroup color + */ +GHOSTTY_API void ghostty_color_palette_generate( + const GhosttyColorRgb* base, + const GhosttyColorPaletteMask* skip, + const GhosttyColorRgb* bg, + const GhosttyColorRgb* fg, + bool harmonious, + GhosttyColorRgb* out); + +/** + * Calculate W3C relative luminance for an RGB color. + * + * Returns a normalized value from 0.0 for black to 1.0 for white. + * See https://www.w3.org/TR/WCAG20/#relativeluminancedef. + * + * @param color The RGB color (must not be NULL) + * @return Relative luminance in the range 0.0 to 1.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_luminance(const GhosttyColorRgb* color); + +/** + * Calculate perceived luminance for an RGB color. + * + * Returns a normalized value from 0.0 for black to 1.0 for white. + * Ghostty treats a background color as light when this exceeds 0.5. + * This is not the metric used internally by + * ghostty_color_palette_generate(), which uses CIELAB lightness. + * + * @param color The RGB color (must not be NULL) + * @return Perceived luminance in the range 0.0 to 1.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_perceived_luminance(const GhosttyColorRgb* color); + +/** + * Calculate the WCAG contrast ratio between two RGB colors. + * + * The contrast ratio is symmetric and ranges from 1.0 for identical + * colors to 21.0 for black and white. + * + * @param a The first RGB color (must not be NULL) + * @param b The second RGB color (must not be NULL) + * @return WCAG contrast ratio in the range 1.0 to 21.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_contrast(const GhosttyColorRgb* a, + const GhosttyColorRgb* b); + +/** + * Get Ghostty's X11 color name table. + * + * The returned pointer references static memory valid for the program + * lifetime and is never NULL. Entries are in rgb.txt order and are + * terminated by an entry with name == NULL. Aliases are separate entries, + * such as "medium spring green" and "MediumSpringGreen". Names are the + * exact supported spellings from rgb.txt; ghostty_color_parse_x11() also + * matches them case-insensitively. + * + * @code{.c} + * for (const GhosttyColorX11Entry* e = ghostty_color_x11_names(); + * e->name != NULL; + * e++) { + * // e->name and e->color are valid here. + * } + * @endcode + * + * @return Pointer to the first X11 color entry + * + * @ingroup color + */ +GHOSTTY_API const GhosttyColorX11Entry* ghostty_color_x11_names(void); + +/** + * Get the number of X11 color name entries. + * + * The returned count excludes the NULL terminator and is provided so + * bindings can preallocate storage before reading ghostty_color_x11_names(). + * + * @return Number of X11 color name entries + * + * @ingroup color + */ +GHOSTTY_API size_t ghostty_color_x11_name_count(void); + #ifdef __cplusplus } #endif +/** @} */ + #endif /* GHOSTTY_VT_COLOR_H */ diff --git a/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h b/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h new file mode 100644 index 00000000..d028ec08 --- /dev/null +++ b/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h @@ -0,0 +1,73 @@ +/** + * @file color_scheme.h + * + * Color scheme report encoding - encode terminal color scheme reports into + * escape sequences. + */ + +#ifndef GHOSTTY_VT_COLOR_SCHEME_H +#define GHOSTTY_VT_COLOR_SCHEME_H + +/** @defgroup color_scheme Color Scheme Report Encoding + * + * Utilities for encoding color scheme reports into terminal escape + * sequences for color scheme reporting mode (mode 2031). + * + * ## Basic Usage + * + * Use ghostty_color_scheme_report_encode() to encode a color scheme report + * into a caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the output + * parameter. + * + * ## Example + * + * @snippet c-vt-color-scheme/src/main.c color-scheme-report-encode + * + * @{ + */ + +#include <stddef.h> +#include <ghostty/vt/types.h> +#include <ghostty/vt/device.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Encode a color scheme report into an escape sequence. + * + * Encodes a color scheme report into the provided buffer. Dark color schemes + * emit ESC [ ? 997 ; 1 n, and light color schemes emit ESC [ ? 997 ; 2 n. + * The encoded bytes are identical to the terminal's internal CSI ? 996 n + * query response. + * + * Hosts should gate unsolicited sends on GHOSTTY_MODE_COLOR_SCHEME_REPORT + * (mode 2031) being set, which can be checked via the mode getters. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param scheme The color scheme to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_color_scheme_report_encode( + GhosttyColorScheme scheme, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_COLOR_SCHEME_H */ diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index d5b50067..dda8b836 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -84,6 +84,28 @@ extern "C" { * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a * @ref GhosttySelection. * + * ## Change Detection + * + * Generation stamps allow renderers to cheaply detect whether Kitty + * graphics state changed between frames: + * + * - @ref GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION is a storage-wide stamp + * updated on any transmit, placement, or delete. If unchanged, the + * placement set and all image data are identical and both placement + * snapshots and per-image staleness checks can be skipped. Placement + * geometry can still change independently (scrolling moves + * placements), so ghostty_kitty_graphics_placement_render_info() + * should still be recomputed on dirty frames. + * - @ref GHOSTTY_KITTY_IMAGE_DATA_GENERATION is a per-image stamp + * changed on every add/replace of that image ID. Texture caches + * should treat a cached texture as stale when this differs from the + * cached value; dimension/length heuristics cannot detect a + * same-sized retransmission. + * + * Stamps are unique and monotonically increasing process-wide, so + * caches keyed on a generation value never alias across screens + * (main/alternate), resets, or terminals. + * * ## Lifetime and Thread Safety * * All handles borrowed from the terminal (GhosttyKittyGraphics, @@ -119,6 +141,29 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttyKittyGraphicsPlacementIterator * */ GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + + /** + * Generation stamp of the last content mutation to this storage: + * any image transmit/replace, placement add, or delete. Zero means + * the storage has never been mutated (and is therefore empty). + * + * If the generation is unchanged since a previous query, the set of + * placements and all image data are identical, so placement iteration + * and image staleness checks can be skipped entirely. Note that + * placement *geometry* may still have changed (scrolling and resizing + * move placements without changing the storage contents), so rendering + * geometry such as ghostty_kitty_graphics_placement_render_info() + * must still be recomputed for frames marked dirty. + * + * Stamps are unique and monotonically increasing process-wide: a + * value observed from any storage never recurs for different content, + * even across screen switches (main vs. alternate screen have + * independent storages) or terminal resets. It is therefore safe to + * key caches on this value alone. + * + * Output type: uint64_t * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION = 2, GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsData; @@ -255,6 +300,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Pixel format of a Kitty graphics image. * + * Note that stored images are always fully decoded: + * GHOSTTY_KITTY_IMAGE_FORMAT_PNG is never returned by + * ghostty_kitty_graphics_image_get() because PNG payloads are decoded + * to GHOSTTY_KITTY_IMAGE_FORMAT_RGBA before storage. The PNG value + * exists only for protocol-level completeness. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -269,6 +320,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Compression of a Kitty graphics image. * + * Note that stored images are always decompressed: + * GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE payloads are inflated + * before storage, so ghostty_kitty_graphics_image_get() always reports + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE. Consumers never need to + * inflate image data themselves. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -315,14 +372,17 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, /** - * Pixel format of the image. + * Pixel format of the image. Never GHOSTTY_KITTY_IMAGE_FORMAT_PNG; + * PNG payloads are decoded to RGBA before storage. * * Output type: GhosttyKittyImageFormat * */ GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, /** - * Compression of the image. + * Compression of the image. Always + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are + * inflated before storage. * * Output type: GhosttyKittyImageCompression * */ @@ -332,25 +392,40 @@ typedef enum GHOSTTY_ENUM_TYPED { * Borrowed pointer to the raw pixel data. Valid as long as the * underlying terminal is not mutated. * + * The data is always fully decoded, uncompressed pixels in the + * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads + * are inflated and PNG payloads are decoded to RGBA at transmission + * time, before the image is stored. Consumers can upload this + * directly to the GPU without any decode step. + * * Output type: const uint8_t ** */ GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, /** - * Length of the raw pixel data in bytes. + * Length of the raw pixel data in bytes. Always equal to + * width * height * bytes-per-pixel for the reported format. * * Output type: size_t * */ GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, /** - * Timestamp of the image's most recent transmission, in nanoseconds. - * The epoch is unspecified; only equality and ordering between values - * read from the same terminal are meaningful. + * Generation stamp assigned when this image was added to (or + * replaced in) the storage. A changed generation for a given image + * ID means the pixel contents may have changed even when the + * dimensions, format, and data length are identical (e.g. a + * retransmission of the same image ID), so texture caches must key + * staleness on this value rather than on size heuristics. + * + * Stamps are unique and monotonically increasing process-wide and + * are drawn from the same sequence as + * GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored + * image, so zero can be used as an "empty" sentinel by callers. * * Output type: uint64_t * */ - GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9, + GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; diff --git a/vendor/libghostty-vt/include/ghostty/vt/render.h b/vendor/libghostty-vt/include/ghostty/vt/render.h index c5b1d0d4..2e3f2f71 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/render.h +++ b/vendor/libghostty-vt/include/ghostty/vt/render.h @@ -39,6 +39,18 @@ extern "C" { * 2. Update it from a terminal instance whenever you need. * 3. Read from the render state to get the data needed to draw your frame. * + * ## Two-Phase Updates + * + * For callers that synchronize terminal access (e.g. a renderer thread + * sharing a lock with an IO thread), the update can be split into two + * phases to minimize the time the terminal must be held exclusively: + * ghostty_render_state_begin_update requires terminal access, while + * ghostty_render_state_end_update completes any deferred work using only + * memory owned by the render state. A typical renderer would lock, begin + * the update, unlock, and then end the update while the IO thread is free + * to continue modifying the terminal. ghostty_render_state_update is a + * convenience that performs both phases in one call. + * * ## Dirty Tracking * * Dirty tracking is a key feature of the render state that allows renderers @@ -331,6 +343,12 @@ GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state); * This consumes terminal/screen dirty state in the same way as the internal * render state update path. * + * This is a convenience function that performs a full update in one call, + * equivalent to ghostty_render_state_begin_update immediately followed by + * ghostty_render_state_end_update. Callers that hold a lock over the + * terminal state should prefer calling the two phases directly so that the + * lock is only held for the begin phase. + * * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or @@ -343,6 +361,54 @@ GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state, GhosttyTerminal terminal); /** + * Begin an update of a render state instance from a terminal. + * + * Every begin must be completed with a ghostty_render_state_end_update call + * before the render state is read. + * + * This two-phase structure exists for callers that synchronize access to the + * terminal state (e.g. with a lock shared with an IO thread): only this + * function requires terminal access, so a caller can hold its lock for this + * call only and then call ghostty_render_state_end_update after releasing + * it. The end phase exclusively reads and writes memory owned by the render + * state, so it is safe to call while the terminal is being modified. + * + * Work that doesn't require terminal access may be deferred to the end phase + * to keep this call (and therefore lock hold time) as short as possible. + * Callers must treat the render state as incomplete until + * ghostty_render_state_end_update is called. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Complete a prior ghostty_render_state_begin_update call by performing any + * deferred work. + * + * This only reads and writes memory owned by the render state, so it is safe + * to call while the terminal is being modified (no terminal synchronization + * is required). Calling this without a prior begin is a safe no-op. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state); + +/** * Get a value from a render state. * * The `out` pointer must point to a value of the type corresponding to the diff --git a/vendor/libghostty-vt/include/ghostty/vt/sgr.h b/vendor/libghostty-vt/include/ghostty/vt/sgr.h index 8eec11dc..757c5aed 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/sgr.h +++ b/vendor/libghostty-vt/include/ghostty/vt/sgr.h @@ -19,8 +19,11 @@ * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) * and returns individual text attributes like bold, italic, colors, etc. * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. + * and handles SGR color attributes including 8-color, 16-color, 256-color, + * direct RGB, underline color, and reset forms. Color values are returned + * using the shared @ref color types; applications that need to parse Ghostty + * config/theme color strings, generate palettes, inspect X11 color names, or + * calculate luminance and contrast should use the @ref color APIs directly. * * ## Basic Usage * diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index ddfcb9c0..26ee0c0b 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -40,6 +40,17 @@ extern "C" { * @snippet c-vt-stream/src/main.c vt-stream-init * @snippet c-vt-stream/src/main.c vt-stream-write * + * ## Scrollback Compression + * + * Scrollback compression is caller-driven. The terminal exposes an opaque + * activity token so an embedding application can restart an idle timer only + * when compression-relevant state changes. Once idle, call incremental + * compression until it no longer reports pending work. libghostty-vt does not + * create a timer or background thread. + * + * @snippet c-vt-compression/src/main.c compression-activity + * @snippet c-vt-compression/src/main.c compression-idle-step + * * ## Effects * * By default, the terminal sequence processing with ghostty_terminal_vt_write() @@ -76,11 +87,13 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -91,6 +104,9 @@ extern "C" { * ### Defining a title_changed callback * @snippet c-vt-effects/src/main.c effects-title-changed * + * ### Defining a clipboard_write callback + * @snippet c-vt-effects/src/main.c effects-clipboard-write + * * ### Registering effects and processing VT data * @snippet c-vt-effects/src/main.c effects-register * @@ -177,6 +193,37 @@ typedef struct { } GhosttyTerminalOptions; /** + * Amount of compression work to perform before returning. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Perform one bounded compression step suitable for idle scheduling. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL = 0, + + /** Synchronously inspect every currently eligible page. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL = 1, + GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionMode; + +/** + * Scheduling result from terminal compression. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Retained-mapping reclamation is unavailable on this target. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED = 0, + + /** More incremental compression work remains. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING = 1, + + /** The pass has no continuation to schedule. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE = 2, + GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionResult; + +/** * Scroll viewport behavior tag. * * @ingroup terminal @@ -190,6 +237,21 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Scroll by a delta amount (up is negative). */ GHOSTTY_SCROLL_VIEWPORT_DELTA, + + /** + * Scroll to an absolute row offset from the top of the scrollable + * area. Row 0 is the top of the scrollback and the requested row + * becomes the first visible row of the viewport. The value is + * clamped so the viewport never scrolls beyond the top of the + * active area. If the terminal has no scrollback (e.g. the + * alternate screen is active), the viewport always remains on the + * active area. + * + * This is the same row space as the offset field of + * GhosttyTerminalScrollbar, so a scrollbar position obtained from + * GHOSTTY_TERMINAL_DATA_SCROLLBAR round-trips cleanly. + */ + GHOSTTY_SCROLL_VIEWPORT_ROW, GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalScrollViewportTag; @@ -202,6 +264,9 @@ typedef union { /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ intptr_t delta; + /** Absolute row offset (only used with GHOSTTY_SCROLL_VIEWPORT_ROW). */ + size_t row; + /** Padding for ABI compatibility. Do not use. */ uint64_t _padding[2]; } GhosttyTerminalScrollViewportValue; @@ -233,6 +298,26 @@ typedef enum GHOSTTY_ENUM_TYPED { } GhosttyTerminalScreen; /** + * Visual style of the terminal cursor. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCursorStyle; + +/** * Scrollbar state for the terminal viewport. * * Represents the scrollable area dimensions needed to render a scrollbar. @@ -264,6 +349,125 @@ typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); /** + * Clipboard destination for a clipboard write. + * + * Protocol-specific destination identifiers are normalized to these values + * before the clipboard write callback is invoked. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The standard system clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_STANDARD = 0, + + /** The selection clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_SELECTION = 1, + + /** The primary selection clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_PRIMARY = 2, + GHOSTTY_CLIPBOARD_LOCATION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardLocation; + +/** + * One MIME representation in a clipboard write. + * + * Both strings are borrowed and valid only for the duration of the callback. + * The data is binary-safe and has already been decoded from any protocol-level + * encoding. A zero-length data string is an explicit empty representation; it + * does not clear the clipboard. + * + * This struct has a frozen layout and will not gain fields in future versions. + * + * @ingroup terminal + */ +typedef struct { + /** MIME type of the representation. */ + GhosttyString mime; + + /** Decoded, binary-safe representation data. */ + GhosttyString data; +} GhosttyClipboardContent; + +/** + * A semantic, atomic clipboard write. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. The request, contents array, MIME strings, and + * data strings are all borrowed and valid only for the callback duration. + * + * All entries in `contents` are representations of the same logical value + * and must be committed atomically. A `contents_len` of zero requests that + * the destination be cleared. This is distinct from a content entry whose data + * has zero length. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Clipboard destination. */ + GhosttyClipboardLocation location; + + /** Borrowed array of MIME representations. */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents; zero means clear the destination. */ + size_t contents_len; +} GhosttyClipboardWrite; + +/** + * Result of a clipboard write callback. + * + * Protocols without write acknowledgements, including OSC 52 and iTerm2 + * OSC 1337 Copy, ignore this result. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The clipboard write completed successfully. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS = 0, + + /** The clipboard write was denied by policy or the user. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED = 1, + + /** The destination or one or more representations are unsupported. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED = 2, + + /** The clipboard is temporarily unavailable. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY = 3, + + /** One or more representations contain invalid data. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA = 4, + + /** The clipboard write failed due to an I/O error. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR = 5, + GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardWriteResult; + +/** + * Callback function type for clipboard_write. + * + * Called synchronously for a complete logical clipboard write. Protocol + * details such as OSC 52 selectors, base64 encoding, multipart chunks, + * aliases, and terminators are normalized before this callback is invoked. + * OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback + * shape. OSC 52 clipboard read requests ("?") are always ignored and never + * forwarded to this callback. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param write Borrowed atomic clipboard write request + * @return The result of attempting the clipboard write + * + * @ingroup terminal + */ +typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardWrite* write); + +/** * Callback function type for color scheme queries (CSI ? 996 n). * * Called when the terminal receives a color scheme device status report @@ -353,6 +557,31 @@ typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, void* userdata); /** + * Callback function type for pwd_changed. + * + * Called when the terminal pwd (current working directory) changes via + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. + * + * The terminal stores whatever bytes the shell emitted, without parsing. + * That means for OSC 7 the value is the raw URI (typically file://...); + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is + * responsible for decoding any URI scheme or host if it cares about them. + * + * The callback also fires when the shell clears the pwd (e.g. an empty + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length + * string. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** * Callback function type for write_pty. * * Called when the terminal needs to write data back to the pty, for @@ -608,6 +837,56 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttySelection* */ GHOSTTY_TERMINAL_OPT_SELECTION = 21, + + /** + * Set the default cursor style used by DECSCUSR reset (CSI 0 q). + * + * A NULL value pointer resets to the built-in default block cursor. + * + * Input type: GhosttyTerminalCursorStyle* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22, + + /** + * Set whether the default cursor should blink when reset by DECSCUSR + * (CSI 0 q). + * + * A NULL value pointer resets to the built-in default of not blinking. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23, + + /** + * Enable or disable Glyph Protocol APC handling. + * + * When disabled, Glyph Protocol APC sequences are ignored and no + * support/query/register/clear responses are emitted. Disabling also clears + * the terminal session's glyph glossary. A NULL value pointer is a no-op. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, + + /** + * Callback invoked when the terminal pwd changes via escape + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL + * to ignore pwd change events. + * + * Input type: GhosttyTerminalPwdChangedFn + */ + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, + + /** + * Callback invoked when the running program performs a clipboard write. + * OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set + * of decoded MIME representations. Set to NULL to ignore clipboard writes. + * Clipboard read requests are always ignored; see + * GhosttyTerminalClipboardWriteFn. + * + * Input type: GhosttyTerminalClipboardWriteFn + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE = 26, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -682,9 +961,16 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Scrollbar state for the terminal viewport. * - * This may be expensive to calculate depending on where the viewport - * is (arbitrary pins are expensive). The caller should take care to only - * call this as needed and not too frequently. + * This is amortized O(1): the total is maintained incrementally as + * the terminal is modified and the viewport offset is cached. The + * first read after the viewport moves to an arbitrary position that + * isn't an absolute row (e.g. scrolling to a selection) may cost + * O(pages) to compute the offset, after which it is cached again. + * + * There is intentionally no change notification for scroll state. + * Callers building scrollbars should poll this once per frame or + * per write batch and diff the result to detect changes; this is + * what Ghostty's own renderer does. * * Output type: GhosttyTerminalScrollbar * */ @@ -1035,7 +1321,10 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, * Scrolls the terminal's viewport according to the given behavior. * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in * the value union to specify the number of rows to scroll (negative - * for up, positive for down). For other behaviors, the value is ignored. + * for up, positive for down). When using GHOSTTY_SCROLL_VIEWPORT_ROW, + * set the row field to the absolute row offset from the top of the + * scrollable area (the same row space as the offset field of + * GhosttyTerminalScrollbar). For other behaviors, the value is ignored. * * @param terminal The terminal handle (may be NULL, in which case this is a no-op) * @param behavior The scroll behavior as a tagged union @@ -1046,6 +1335,60 @@ GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, GhosttyTerminalScrollViewport behavior); /** + * Return the current compression activity token. + * + * The token is opaque and only equality comparisons are meaningful. An + * embedding application should cache it and restart its compression idle + * delay whenever the value changes. The value may wrap and changes in either + * direction have the same meaning. + * + * This function only observes terminal state. It does not perform or schedule + * compression. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_activity Receives the current activity token + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * is NULL + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compression_activity( + GhosttyTerminal terminal, + uint64_t* out_activity); + +/** + * Compress eligible terminal scrollback. + * + * Incremental mode performs bounded work suitable for an idle callback. A + * pending result means the application should invoke another step while the + * terminal remains idle. A complete result means no continuation is needed + * until ghostty_terminal_compression_activity() changes. Full mode performs + * one synchronous scan and can stall on large scrollback buffers. + * + * Compression is opportunistic. Complete means the pass has finished, not + * that every page was compressed: pages may be unprofitable or encounter an + * allocation or reclamation failure. Compression changes only the terminal's + * storage representation and never its logical contents or scrollback limit. + * Accessing compressed history restores it transparently. + * + * This function is not thread-safe with other operations on the same + * terminal. The caller must serialize it with writes, rendering, searches, + * and other terminal access. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The amount of compression work to perform + * @param[out] out_result Receives the compression scheduling result + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * or mode is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compress( + GhosttyTerminal terminal, + GhosttyTerminalCompressionMode mode, + GhosttyTerminalCompressionResult* out_result); + +/** * Get the current value of a terminal mode. * * Returns the value of the mode identified by the given mode. diff --git a/vendor/libghostty-vt/include/ghostty/vt/unicode.h b/vendor/libghostty-vt/include/ghostty/vt/unicode.h new file mode 100644 index 00000000..e4d62c81 --- /dev/null +++ b/vendor/libghostty-vt/include/ghostty/vt/unicode.h @@ -0,0 +1,154 @@ +/** + * @file unicode.h + * + * Unicode utilities - codepoint properties matching the terminal's + * text layout semantics. + */ + +#ifndef GHOSTTY_VT_UNICODE_H +#define GHOSTTY_VT_UNICODE_H + +/** @defgroup unicode Unicode Utilities + * + * Unicode codepoint properties matching the terminal's text layout + * semantics. + * + * ## Basic Usage + * + * Use ghostty_unicode_codepoint_width() to determine how many terminal + * grid cells a codepoint occupies, using the exact same width table the + * terminal itself uses when laying out printed text. Use + * ghostty_unicode_grapheme_width() to segment and measure full grapheme + * clusters with the same rules the terminal uses when mode 2027 is + * enabled. These functions are useful for predicting column layout of + * text that has not yet been written to the terminal, such as IME + * preedit (composition) overlays. + * + * @{ + */ + +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Returns the terminal display width of a Unicode codepoint in + * terminal grid cells: 0, 1, or 2. + * + * This is the same width table the terminal itself uses when laying + * out printed text, so callers can predict column layout (e.g. IME + * preedit overlays) that exactly matches what the terminal will do + * when the text is actually written to it. + * + * Semantics: + * - Returns 0 for zero-width codepoints: C0/C1 control characters, + * nonspacing and enclosing combining marks, default-ignorable + * codepoints (ZWJ, ZWNJ, variation selectors, etc.), and + * surrogate codepoints. + * - Returns 2 for wide codepoints: East Asian Wide/Fullwidth + * (including emoji with default emoji presentation) and regional + * indicators. Width is clamped to 2 (e.g. the three-em dash). + * - Returns 1 for everything else, including invalid codepoints + * beyond U+10FFFF (this function is total; it never fails). + * + * This operates on a single codepoint only and therefore cannot account + * for grapheme-cluster-level width rules (VS16 emoji presentation, + * combining sequences, etc.). For cluster-accurate widths, use + * ghostty_unicode_grapheme_width(). Summing per-codepoint widths is only + * correct when mode 2027 (grapheme clustering) is disabled. + * + * This function is pure, allocates nothing, and is thread-safe. + * + * @param cp The Unicode codepoint to measure + * @return Display width in cells: 0, 1, or 2 + */ +GHOSTTY_API uint8_t ghostty_unicode_codepoint_width(uint32_t cp); + +/** + * Measures the terminal display width of the first grapheme cluster in a + * sequence of Unicode codepoints. + * + * This uses the exact same grapheme segmentation and cluster width rules + * the terminal itself uses when printing text with grapheme clustering + * enabled (mode 2027), so callers can predict column layout (e.g. IME + * preedit overlays) that exactly matches what the terminal will do when + * the text is actually written to it. Unlike + * ghostty_unicode_codepoint_width(), this accounts for cluster-level + * rules: emoji variation selectors, ZWJ sequences, combining marks, and + * skin tone modifiers. + * + * Reads codepoints from cps until the terminal would consider the + * grapheme cluster complete, stores the cluster's total width in cells + * (0, 1, or 2) into width (which may be NULL if only segmentation is + * desired), and returns the number of codepoints consumed. Returns 0 if + * and only if len is 0; otherwise consumes at least one codepoint. Measure + * a whole string by calling in a loop: + * + * @code + * size_t total = 0; + * for (size_t i = 0; i < len;) { + * uint8_t width; + * i += ghostty_unicode_grapheme_width(cps + i, len - i, &width); + * total += width; + * } + * @endcode + * + * This is not a streaming API. The provided sequence must contain a + * complete first grapheme cluster, or the logical end of the string. If + * input arrives in chunks, keep buffering while this function consumes all + * available codepoints (return value == len) and the stream may still + * continue; a later codepoint could still extend the cluster and change + * its width. + * + * Width semantics, matching the terminal with mode 2027 enabled: + * - The cluster starts at the width of its first codepoint, as returned by + * ghostty_unicode_codepoint_width(). + * - VS16 (U+FE0F) forces the cluster wide (2) and VS15 (U+FE0E) forces it + * narrow (1), but only when the immediately preceding codepoint in the + * cluster is a valid emoji variation sequence base (per Unicode + * emoji-variation-sequences.txt). Invalid variation selectors are + * ignored entirely. + * - Any other continuation codepoint that contributes to grapheme width + * forces the cluster wide (2). Note this means cluster width is NOT the + * maximum of per-codepoint widths: some continuation marks have narrow + * codepoint width yet still widen the cluster. + * + * Mode dependence: this models mode 2027 (grapheme clustering) enabled, + * which is Ghostty's recommended configuration. When mode 2027 is + * disabled, clusters never combine and variation selectors never change + * width; predict layout in that case by summing + * ghostty_unicode_codepoint_width() over each codepoint instead. + * + * Edge cases: + * - Codepoints beyond U+10FFFF consume one codepoint, have width 1, and + * are always cluster boundaries. This function is total; it never fails. + * - Control characters (C0/C1, CR, LF) are never printed through the + * terminal's text path; passing them here returns an unspecified (but + * stable and bounded) result. + * - A cluster whose first codepoint is zero-width (e.g. a lone combining + * mark) is malformed at a cell start; the terminal may attach it to + * earlier screen content. This function reports the fold result for the + * sequence in isolation (typically 0). + * + * This function is pure, allocates nothing, and is thread-safe. + * + * @param cps Pointer to codepoints (may be NULL only when len is 0) + * @param len Number of codepoints available + * @param width Out: cluster display width in cells (0-2); may be NULL + * @return Number of codepoints in the first grapheme cluster + */ +GHOSTTY_API size_t ghostty_unicode_grapheme_width(const uint32_t *cps, + size_t len, + uint8_t *width); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_UNICODE_H */ diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 410f717b..2c4edc49 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -1217,7 +1217,7 @@ fn selectionScrollTick(self: *Surface) !void { // We modified our viewport and selection so we need to queue // a render. - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } @@ -2326,23 +2326,46 @@ fn copySelectionToClipboards( }; } -/// Set the selection contents. +/// Set the active selection and notify the apprt on a genuine state +/// transition. All selection mutations route through here rather than +/// `screen.select` directly so the notification fires consistently. To +/// also copy per `copy_on_select`, use `setSelectionAndCopy`. /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { + // Compute the transition before `select` below, which untracks (frees) + // the previous selection's tracked pins; reading them after would be a + // use-after-free. const prev_ = self.io.terminal.screens.active.selection; + const changed = changed: { + const prev = prev_ orelse break :changed sel_ != null; + const sel = sel_ orelse break :changed true; + break :changed !sel.eql(prev); + }; + try self.io.terminal.screens.active.select(sel_); + if (changed) { + _ = self.rt_app.performAction( + .{ .surface = self }, + .selection_changed, + {}, + ) catch |err| { + log.warn("apprt failed selection_changed notification err={}", .{err}); + }; + } +} + +/// Set a selection and, per `copy_on_select`, copy it to the clipboard. +/// For committing selection gestures (mouse release, select-all binding). +/// +/// This must be called with the renderer mutex held. +fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void { + try self.setSelection(sel); + // If copy on select is false then exit early. if (self.config.copy_on_select == .false) return; - // Set our selection clipboard. If the selection is cleared we do not - // clear the clipboard. If the selection is set, we only set the clipboard - // again if it changed, since setting the clipboard can be an expensive - // operation. - const sel = sel_ orelse return; - if (prev_) |prev| if (sel.eql(prev)) return; - switch (self.config.copy_on_select) { .false => unreachable, // handled above with an early exit @@ -3840,7 +3863,7 @@ pub fn mouseButtonCallback( if (self.config.copy_on_select != .false) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { - try self.setSelection(terminal.Selection.init( + try self.setSelectionAndCopy(terminal.Selection.init( prev.start(), prev.end(), prev.rectangle, @@ -3985,16 +4008,16 @@ pub fn mouseButtonCallback( else => unreachable, } - // We set the selection directly rather than use `setSelection` because - // we want to avoid copying the selection to the selection clipboard. - // For left mouse clicks we only set the clipboard on release. + // Use `setSelection` (not `setSelectionAndCopy`) here to avoid + // touching the selection clipboard: for left mouse clicks we only + // copy on release. if (press_selection) |selection| { - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } else if (self.mouse.selection_gesture.left_click_count == 1 and self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); + try self.setSelection(null); try self.queueRender(); } } @@ -4060,13 +4083,13 @@ pub fn mouseButtonCallback( // If there is a link at this position, we want to // select the link. Otherwise, select the word. if (try self.linkAtPos(pos)) |link| { - try self.setSelection(link.selection); + try self.setSelectionAndCopy(link.selection); } else { const sel = screen.selectWord( pin, self.config.selection_word_chars, ) orelse break :sel; - try self.setSelection(sel); + try self.setSelectionAndCopy(sel); } try self.queueRender(); @@ -4195,20 +4218,24 @@ fn maybePromptClick(self: *Surface) !bool { // Guarded at the start of this function .none => unreachable, - .click_events => { + .click_events => |v| { // For the event, we always send a left-click press event. // This matches what Kitty sends. + const key: u8, const y: u32 = switch (v) { + .absolute => .{ 1, pos_vp.y +| 1 }, + .relative => .{ 2, pos_vp.y -| prompt_pin.y +| 1 }, + }; var data: termio.Message.WriteReq.Small.Array = undefined; const resp = try std.fmt.bufPrint( &data, "\x1B[<0;{d};{d}M", - .{ pos_vp.x + 1, pos_vp.y + 1 }, + .{ pos_vp.x + 1, y }, ); // Not that noisy since this only happens on prompt clicks. log.debug( - "sending click_events=1 event=ESC{s}", - .{resp[1..]}, + "sending click_events={} event=ESC{s}", + .{ key, resp[1..] }, ); // Ask our IO thread to write the data @@ -4411,7 +4438,7 @@ fn openUrl( /// if there is no hyperlink. fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { _ = self; - const page = &pin.node.data; + const page = pin.node.page(); const cell = pin.rowAndCell().cell; const link_id = page.lookupHyperlink(cell) orelse return null; const entry = page.hyperlink_set.get(page.memory, link_id); @@ -4464,7 +4491,7 @@ pub fn mousePressureCallback( ); } - try self.io.terminal.screens.active.select(sel orelse break :select); + try self.setSelection(sel orelse break :select); try self.queueRender(); } } @@ -4669,7 +4696,7 @@ pub fn cursorPosCallback( } // Update our selection based on the gesture state - try self.io.terminal.screens.active.select(drag_selection); + try self.setSelection(drag_selection); } } @@ -5420,7 +5447,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { - try self.setSelection(s); + try self.setSelectionAndCopy(s); try self.queueRender(); } }, diff --git a/vendor/libghostty-vt/src/apprt/action.zig b/vendor/libghostty-vt/src/apprt/action.zig index f6865af8..4728d73c 100644 --- a/vendor/libghostty-vt/src/apprt/action.zig +++ b/vendor/libghostty-vt/src/apprt/action.zig @@ -295,6 +295,10 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Called when the active selection changes. The apprt should read the + /// current selection itself; this carries no payload. + selection_changed, + /// Undo the last action. See the "undo" keybinding for more /// details on what can and cannot be undone. undo, @@ -396,6 +400,7 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + selection_changed, undo, redo, check_for_updates, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig index 107510b4..a627086e 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig @@ -743,6 +743,9 @@ pub const Application = extern struct { .ring_bell => Action.ringBell(target), + // GTK has no accessibility consumer for this yet. + .selection_changed => {}, + .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value), diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig index cf0f31a6..28987c25 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig @@ -108,34 +108,35 @@ pub const GlobalShortcuts = extern struct { fn close(self: *Self) void { const priv = self.private(); - const dbus = priv.dbus_connection orelse return; - if (priv.response_subscription != 0) { - dbus.signalUnsubscribe(priv.response_subscription); - priv.response_subscription = 0; - } + if (priv.dbus_connection) |dbus| { + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } - if (priv.activate_subscription != 0) { - dbus.signalUnsubscribe(priv.activate_subscription); - priv.activate_subscription = 0; - } + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } - if (priv.handle) |handle| { - // Close existing session - dbus.call( - "org.freedesktop.portal.Desktop", - handle, - "org.freedesktop.portal.Session", - "Close", - null, - null, - .{}, - -1, - null, - null, - null, - ); - priv.handle = null; + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } } if (priv.arena) |*arena| { @@ -151,7 +152,8 @@ pub const GlobalShortcuts = extern struct { const priv = self.private(); - // We need configuration to proceed. + // We need a dbus connection and configuration to proceed. + if (priv.dbus_connection == null) return; const config = if (priv.config) |v| v.get() else return; // Setup our new arena that we'll use for memory allocations. diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig index 5aef53d2..7a6b66de 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig @@ -193,6 +193,7 @@ pub const SplitTree = extern struct { .init("new-split", actionNewSplit, s_variant_type), .init("equalize", actionEqualize, null), .init("zoom", actionZoom, null), + .init("close-split", actionCloseSplit, null), }; _ = ext.actions.addAsGroup(Self, self, "split-tree", &actions); @@ -683,6 +684,15 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); } + pub fn actionCloseSplit( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.getActiveSurface() orelse return; + surface.close(); + } + fn surfaceCloseRequest( surface: *Surface, self: *Self, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig index 629866dc..54825ce0 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig @@ -3030,37 +3030,56 @@ pub const Surface = extern struct { ) callconv(.c) c_int { const priv: *Private = self.private(); - switch (ec.getUnit()) { - .surface => {}, - .wheel => return @intFromBool(false), - else => return @intFromBool(false), - } + // Check if horizontal tab scrolling is enabled and this is a + // touchpad surface scroll. If not, forward to the terminal. + const tab_scroll_enabled = if (priv.config) |config| + config.get().@"gtk-horizontal-tab-scroll" + else + true; + + const is_surface_scroll = ec.getUnit() == .surface; + + if (tab_scroll_enabled and is_surface_scroll) { + priv.pending_horizontal_scroll += x; + + if (@abs(priv.pending_horizontal_scroll) < 120) { + if (priv.pending_horizontal_scroll_reset) |v| { + _ = glib.Source.remove(v); + priv.pending_horizontal_scroll_reset = null; + } + priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self); + return @intFromBool(true); + } - priv.pending_horizontal_scroll += x; + _ = self.as(gtk.Widget).activateAction( + if (priv.pending_horizontal_scroll < 0.0) + "tab.next-page" + else + "tab.previous-page", + null, + ); - if (@abs(priv.pending_horizontal_scroll) < 120) { if (priv.pending_horizontal_scroll_reset) |v| { _ = glib.Source.remove(v); priv.pending_horizontal_scroll_reset = null; } - priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self); - return @intFromBool(true); - } - _ = self.as(gtk.Widget).activateAction( - if (priv.pending_horizontal_scroll < 0.0) - "tab.next-page" - else - "tab.previous-page", - null, - ); + priv.pending_horizontal_scroll = 0.0; - if (priv.pending_horizontal_scroll_reset) |v| { - _ = glib.Source.remove(v); - priv.pending_horizontal_scroll_reset = null; + return @intFromBool(true); } - priv.pending_horizontal_scroll = 0.0; + // Forward horizontal scroll to the terminal (e.g. for neovim). + const surface = priv.core_surface orelse return @intFromBool(false); + const scaled = self.scaledCoordinates(x, 0); + surface.scrollCallback( + scaled.x * -1, + 0, + .{}, + ) catch |err| { + log.warn("error in scroll callback err={}", .{err}); + return @intFromBool(false); + }; return @intFromBool(true); } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig index 2a1a5435..c34f2cb4 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig @@ -1156,6 +1156,38 @@ pub const Window = extern struct { }; } + fn toplevelComputeSize( + _: *gdk.Toplevel, + size: *gdk.ToplevelSize, + self: *Self, + ) callconv(.c) void { + // The compositor/quick terminal own the size in these states. + if (self.isMaximized() or + self.isFullscreen() or + self.isQuickTerminal()) return; + + // If there's no GdkSurface yet these dimensions will be zero size which + // will make the window start out as small as possible. These checks ensure + // we don't start with a 0, 0 window size. + const gdk_surface = self.as(gtk.Native).getSurface() orelse return; + const w = gdk_surface.getWidth(); + const h = gdk_surface.getHeight(); + if (w <= 0 or h <= 0) return; + + // GTK clamps the requested size to the compositor-reported bounds, which + // go stale when the window moves to a larger monitor. Only re-assert the + // current size when it exceeds those bounds; otherwise let GTK's default + // sizing win so a freshly-mapped window isn't forced to a tiny size. + var bounds_w: c_int = undefined; + var bounds_h: c_int = undefined; + size.getBounds(&bounds_w, &bounds_h); + + if (bounds_w <= 0 or bounds_h <= 0) return; + if (w <= bounds_w and h <= bounds_h) return; + + size.setSize(w, h); + } + fn propFullscreened( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -1330,6 +1362,16 @@ pub const Window = extern struct { self, .{ .detail = "height" }, ); + // Connect after GTK's compute-size handler so our size wins. + if (gobject.ext.cast(gdk.Toplevel, gdk_surface)) |toplevel| { + _ = gdk.Toplevel.signals.compute_size.connect( + toplevel, + *Self, + toplevelComputeSize, + self, + .{ .after = true }, + ); + } } // When we are realized we always setup our appearance since this diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp index 2f01c48c..2ae0a347 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp @@ -318,6 +318,11 @@ menu context_menu_model { action: "split-tree.new-split"; target: "right"; } + + item { + label: _("Close Split"); + action: "split-tree.close-split"; + } } submenu { diff --git a/vendor/libghostty-vt/src/benchmark/ApcParser.zig b/vendor/libghostty-vt/src/benchmark/ApcParser.zig new file mode 100644 index 00000000..73b7c57a --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/ApcParser.zig @@ -0,0 +1,143 @@ +//! This benchmark tests the throughput of APC sequence parsing +//! through the terminal stream: VT state machine dispatch, APC +//! protocol identification, and the protocol command parsers +//! (e.g. Kitty graphics). Completed commands are parsed and then +//! discarded; command execution (image decoding, storage) is not +//! included so this isolates the parsing path. +const ApcParser = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); + +const log = std.log.scoped(.@"apc-parser-bench"); + +opts: Options, +stream: Stream, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +const Stream = terminalpkg.Stream(Handler); + +/// A stream handler that only processes APC actions, parsing and +/// immediately discarding completed commands. +const Handler = struct { + alloc: Allocator, + apc: terminalpkg.apc.Handler = .{}, + + pub fn deinit(self: *Handler) void { + self.apc.deinit(); + } + + pub fn vt( + self: *Handler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) void { + switch (action) { + .apc_start => self.apc.start(), + .apc_put => self.apc.feed(self.alloc, value), + .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), + .apc_end => if (self.apc.end()) |cmd| { + var c = cmd; + std.mem.doNotOptimizeAway(&c); + c.deinit(self.alloc); + }, + else => {}, + } + } +}; + +/// Create a new APC parser benchmark for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*ApcParser { + const ptr = try alloc.create(ApcParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .stream = .init(.{ .alloc = alloc }), + }; + return ptr; +} + +pub fn destroy(self: *ApcParser, alloc: Allocator) void { + self.stream.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *ApcParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; + var f_reader = f.reader(&read_buf); + const r = &f_reader.interface; + + // This buffer size matches the read buffer size used by the + // real IO thread (see termio Exec.zig buffer_capacity) so that + // the benchmark exercises the stream with realistic chunk sizes. + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + self.stream.nextSlice(buf[0..n]); + } +} + +test ApcParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *ApcParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig new file mode 100644 index 00000000..166c3d4f --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig @@ -0,0 +1,155 @@ +//! Benchmark hyperlink cell-map lookups and remove/insert churn. +//! +//! Hyperlink cells are stored in a fixed-capacity, open-addressed hash map. +//! The `churn` mode models terminal output that repeatedly replaces cells in +//! a page whose hyperlink map is already close to full. This is particularly +//! useful for catching probe-length cliffs at high load factors. +const HyperlinkMap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const hyperlink = @import("../terminal/hyperlink.zig"); +const Benchmark = @import("Benchmark.zig"); + +const log = std.log.scoped(.@"hyperlink-map-bench"); + +opts: Options, +page: terminal.Page, +link_id: hyperlink.Id, +entry_count: usize, + +pub const Options = struct { + /// Requested hyperlink working-set size. Must be a power of two and at + /// least 16. The map may reserve additional probe headroom. + entries: u16 = 4096, + + /// Percentage of the map populated before the timed operation. + /// Values above 100 are treated as 100. + @"load-percent": u8 = 100, + + /// Number of complete passes over the populated cells per step. + loops: u16 = 1, + + /// Operation to perform in the timed region. + mode: Mode = .churn, +}; + +pub const Mode = enum { + /// Look up every populated hyperlink cell. + lookup, + + /// Remove and reinsert every populated hyperlink cell. + churn, +}; + +pub fn create(alloc: Allocator, opts: Options) !*HyperlinkMap { + if (opts.entries < 16 or !std.math.isPowerOfTwo(opts.entries)) { + log.err("entries must be a power of two greater than or equal to 16", .{}); + return error.InvalidEntries; + } + + const ptr = try alloc.create(HyperlinkMap); + errdefer alloc.destroy(ptr); + + // The page requests one map slot per `hyperlink_cell_multiplier` set + // entries. Keep this relationship explicit so `entries` is the working + // set size under test regardless of the map's reserved probe headroom. + const set_entries = opts.entries / 16; + var page = try terminal.Page.init(.{ + .cols = opts.entries, + .rows = 1, + .hyperlink_bytes = @intCast( + @as(usize, set_entries) * @sizeOf(hyperlink.Set.Item), + ), + }); + errdefer page.deinit(); + + if (page.hyperlinkCapacity() < opts.entries) { + log.err("insufficient map capacity expected_at_least={} actual={}", .{ + opts.entries, + page.hyperlinkCapacity(), + }); + return error.UnexpectedCapacity; + } + + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = "https://example.com/benchmark", + }); + + const load = @min(opts.@"load-percent", 100); + const entry_count = @max( + 1, + @divFloor(@as(usize, opts.entries) * load, 100), + ); + for (0..entry_count) |x| { + const rac = page.getRowAndCell(x, 0); + page.hyperlink_set.use(page.memory, link_id); + try page.setHyperlink(rac.row, rac.cell, link_id); + } + + ptr.* = .{ + .opts = opts, + .page = page, + .link_id = link_id, + .entry_count = entry_count, + }; + return ptr; +} + +pub fn destroy(self: *HyperlinkMap, alloc: Allocator) void { + self.page.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *HyperlinkMap) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .lookup => stepLookup, + .churn => stepChurn, + }, + }); +} + +fn stepLookup(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const cell = self.page.getRowAndCell(x, 0).cell; + const id = self.page.lookupHyperlink(cell) orelse + return error.BenchmarkFailed; + std.mem.doNotOptimizeAway(id); + } + } +} + +fn stepChurn(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const rac = self.page.getRowAndCell(x, 0); + self.page.clearHyperlink(rac.cell); + self.page.hyperlink_set.use(self.page.memory, self.link_id); + self.page.setHyperlink(rac.row, rac.cell, self.link_id) catch + return error.BenchmarkFailed; + } + } +} + +test HyperlinkMap { + const alloc = std.testing.allocator; + + inline for (.{ Mode.lookup, Mode.churn }) |mode| { + const impl = try HyperlinkMap.create(alloc, .{ + .entries = 64, + .mode = mode, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/PageCompression.zig b/vendor/libghostty-vt/src/benchmark/PageCompression.zig new file mode 100644 index 00000000..21153659 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/PageCompression.zig @@ -0,0 +1,519 @@ +//! Benchmarks raw LZ4 compression and decompression on page-sized byte +//! buffers. +//! +//! This benchmark is intentionally independent of terminal page ownership and +//! lifecycle. It treats its input as opaque bytes and calls only the standalone +//! LZ4 block codec. In particular, it does not compress pages owned by a live +//! terminal or measure the scheduling and state transitions used by automatic +//! scrollback compression. +//! +//! ## Input +//! +//! `--data` names a pre-generated raw byte corpus. The corpus is divided into +//! `--page-size` byte chunks, with a final short chunk retained when the file +//! size is not an exact multiple. The default page size is 400 KiB, matching a +//! standard terminal page in ReleaseFast builds on the current target. +//! +//! A raw dump of actual page backing memory is the most representative input: +//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata, +//! and unused capacity exactly as the codec would see them. Keep such corpora +//! outside the repository and reuse the same file when comparing branches. +//! Arbitrary files are accepted too, but their compression ratios should not be +//! interpreted as terminal scrollback ratios. +//! +//! ## Modes +//! +//! * `noop` walks the input chunks without invoking the codec. This measures the +//! benchmark loop's minimum overhead. +//! * `compress` compresses every input chunk into a reusable output buffer. +//! * `store` additionally copies each useful result into an exact-sized +//! allocation. It retains the most recent `--retained-pages` blocks so that +//! allocation, eviction, and allocator reuse resemble bounded scrollback. +//! * `decompress` prepares compressed blocks during setup, then decompresses +//! every block into a reusable output buffer. +//! * `report` compresses each chunk once and prints raw and encoded sizes. It is +//! for inspecting ratios, not timing comparisons. +//! +//! Dataset loading, output allocation, and preparation of blocks for +//! decompression happen in `setup` and are outside `Benchmark`'s timed region. +//! The `compress` and `decompress` steps perform no allocation. `store` +//! deliberately performs encoded-block allocations and frees inside the timed +//! step; only its reusable workspace and ring metadata are prepared in setup. +//! `hyperfine` still measures full process lifetime, so use `--loops` to +//! amortize setup and teardown when comparing small corpora. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the compression ratio of a page corpus: +//! +//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw +//! +//! Measure the cost of exact encoded storage relative to the codec alone: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \ +//! 'ghostty-bench +page-compression --mode=store --loops=100 --data=/tmp/pages.raw' +const PageCompression = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const compress = @import("../terminal/compress.zig"); +const CompressedPage = compress.Page; +const lz4 = compress.lz4; + +const log = std.log.scoped(.@"page-compression-bench"); + +/// Prevent a malformed or accidentally enormous corpus from consuming +/// unbounded memory during benchmark setup. +const max_data_size = 64 * 1024 * 1024; + +alloc: Allocator, +opts: Options, + +/// Complete contents of the input corpus. Individual pages are slices into +/// this allocation, so it remains alive until teardown. +data: []u8 = &.{}, + +/// Compressed blocks prepared during setup for `decompress` mode. +encoded: std.ArrayList(Encoded) = .empty, + +/// Exact encoded allocations retained by `store` mode. Null entries have not +/// been reached yet; once full, `stored_next` identifies the oldest block. +stored: []?[]u8 = &.{}, +stored_next: usize = 0, + +/// Reused by compression and report modes. Its length is the compression +/// bound of the largest input chunk. +compression_output: []u8 = &.{}, + +/// Reused by decompression mode. Its length is at least one input chunk. +decompression_output: []u8 = &.{}, + +/// Fixed 16 KiB scratch table required by the compressor. +table: lz4.HashTable = undefined, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .compress, + + /// Repeat the complete corpus this many times per benchmark step. Increase + /// this when the corpus is too small for stable `hyperfine` measurements. + loops: u32 = 1, + + /// Number of bytes treated as one independent LZ4 block. Real page dumps + /// should use the exact backing-memory size of the pages being measured. + @"page-size": usize = 400 * 1024, + + /// Number of exact encoded allocations retained by `store` mode before + /// the oldest allocation is freed and replaced. The default approximates + /// a 10 MB scrollback composed of standard 400 KiB terminal pages. + @"retained-pages": usize = 25, + + /// Pre-generated input corpus. `-` reads stdin, although a regular file is + /// recommended so identical bytes can be reused across benchmark runs. + /// When unset, all modes are no-ops. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Walk page boundaries and establish the benchmark loop overhead. + noop, + + /// Compress each raw page into a reusable compression-bound buffer. + compress, + + /// Compress and retain exact-sized encoded blocks in a bounded ring. + store, + + /// Decompress blocks prepared before the timed region. + decompress, + + /// Print per-page and aggregate encoded sizes. Not a timing benchmark. + report, +}; + +const Encoded = struct { + /// Exact compressed block bytes. + bytes: []u8, + + /// Exact output length expected by the raw block decoder. + raw_len: usize, +}; + +/// Allocate benchmark state. Input data is intentionally loaded later by +/// `setup` so construction is cheap and follows the other benchmarks. +pub fn create( + alloc: Allocator, + opts: Options, +) !*PageCompression { + const ptr = try alloc.create(PageCompression); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + }; + return ptr; +} + +/// Release allocations retained across benchmark steps. +pub fn destroy(self: *PageCompression, alloc: Allocator) void { + self.clearPreparedData(); + alloc.destroy(self); +} + +/// Select one operation for the benchmark harness to time. +pub fn benchmark(self: *PageCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .store => stepStore, + .decompress => stepDecompress, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Load and partition the input corpus. For decompression mode this also +/// creates the encoded blocks, keeping compression outside the timed region. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + assert(self.data.len == 0); + assert(self.encoded.items.len == 0); + + self.setupData() catch |err| { + log.warn("failed to prepare page compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupData(self: *PageCompression) !void { + if (self.opts.loops == 0) return error.InvalidLoops; + if (self.opts.@"page-size" == 0) return error.InvalidPageSize; + if (self.opts.mode == .store and self.opts.@"retained-pages" == 0) + return error.InvalidRetainedPages; + + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + self.data = try data_file.readToEndAlloc(self.alloc, max_data_size); + errdefer { + self.alloc.free(self.data); + self.data = &.{}; + } + if (self.data.len == 0) return; + if (self.opts.mode == .noop) return; + + const largest_page = @min(self.opts.@"page-size", self.data.len); + self.compression_output = try self.alloc.alloc( + u8, + try lz4.compressBound(largest_page), + ); + errdefer { + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + } + + if (self.opts.mode == .store) try self.prepareStored(); + if (self.opts.mode == .decompress) try self.prepareEncoded(); +} + +/// Allocate only the ring metadata for `store` mode. The encoded blocks which +/// populate it are intentionally allocated by the timed benchmark step. +fn prepareStored(self: *PageCompression) !void { + self.stored = try self.alloc.alloc(?[]u8, self.opts.@"retained-pages"); + @memset(self.stored, null); + self.stored_next = 0; +} + +/// Precompress every input page and verify one decode before benchmarking. +/// This catches corpus or codec problems before the timer starts. +fn prepareEncoded(self: *PageCompression) !void { + self.decompression_output = try self.alloc.alloc( + u8, + @min(self.opts.@"page-size", self.data.len), + ); + errdefer { + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + } + + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = try lz4.compress( + page, + self.compression_output, + &self.table, + ); + const encoded = try self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ); + self.encoded.append(self.alloc, .{ + .bytes = encoded, + .raw_len = page.len, + }) catch |err| { + self.alloc.free(encoded); + return err; + }; + } + + var page_it = self.pages(); + for (self.encoded.items) |block| { + const page = page_it.next().?; + const output = self.decompression_output[0..block.raw_len]; + _ = try lz4.decompress(block.bytes, output); + if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch; + } +} + +/// Release everything created by setup. This is shared by teardown and +/// destroy so errors and direct unit-test use remain leak-free. +fn clearPreparedData(self: *PageCompression) void { + for (self.encoded.items) |block| self.alloc.free(block.bytes); + self.encoded.deinit(self.alloc); + self.encoded = .empty; + + for (self.stored) |block| if (block) |bytes| self.alloc.free(bytes); + if (self.stored.len > 0) self.alloc.free(self.stored); + self.stored = &.{}; + self.stored_next = 0; + + if (self.compression_output.len > 0) + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + + if (self.decompression_output.len > 0) + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + + if (self.data.len > 0) self.alloc.free(self.data); + self.data = &.{}; +} + +fn teardown(ptr: *anyopaque) void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + self.clearPreparedData(); +} + +/// Baseline mode: traverse exactly the same page boundaries as compression +/// without invoking the codec. +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| std.mem.doNotOptimizeAway(page); + } +} + +/// Compress all pages into one reusable output buffer. Only the returned +/// encoded length is consumed because retaining output pages would measure +/// allocation and ownership rather than codec throughput. +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(encoded_len); + } + } +} + +/// Compress pages and retain their exact encoded allocations in a bounded +/// ring. This models the part of `compress.Page.init` which differs from the +/// allocation-free codec benchmark: copying the result, allocating its +/// persistent storage, and freeing an old block when scrollback is full. +fn stepStore(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + assert(self.stored.len > 0); + + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const output_len = CompressedPage.requiredScratch(page.len) catch |err| { + log.warn("failed to size stored page output err={}", .{err}); + return error.BenchmarkFailed; + }; + if (output_len == 0) continue; + + const encoded_len = lz4.compress( + page, + self.compression_output[0..output_len], + &self.table, + ) catch |err| switch (err) { + // This is the same profitability outcome as + // compress.Page.init: a block which exceeds the useful output + // limit remains resident and consumes no encoded allocation. + error.OutputTooSmall => continue, + error.InputTooLarge => { + log.warn("stored page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }, + }; + + const encoded = self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ) catch |err| { + log.warn("stored page allocation failed err={}", .{err}); + return error.BenchmarkFailed; + }; + + const slot = &self.stored[self.stored_next]; + if (slot.*) |previous| self.alloc.free(previous); + slot.* = encoded; + self.stored_next = (self.stored_next + 1) % self.stored.len; + std.mem.doNotOptimizeAway(encoded); + } + } +} + +/// Decompress blocks prepared by setup. The output allocation is reused so +/// this measures only decoding and the required memory writes. +fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + for (self.encoded.items) |block| { + const output = self.decompression_output[0..block.raw_len]; + _ = lz4.decompress(block.bytes, output) catch |err| { + log.warn("page decompression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(output); + } + } +} + +/// Print size information for evaluating compression ratio. This shares the +/// input and codec paths with compression mode but deliberately makes no +/// timing claims. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + + var page_index: usize = 0; + var raw_total: usize = 0; + var encoded_total: usize = 0; + var it = self.pages(); + while (it.next()) |page| : (page_index += 1) { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression report failed err={}", .{err}); + return error.BenchmarkFailed; + }; + raw_total += page.len; + encoded_total += encoded_len; + std.debug.print( + "page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n", + .{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) }, + ); + } + + std.debug.print( + "page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++ + "workspace={d} output_bound={d}\n", + .{ + page_index, + raw_total, + encoded_total, + percentage(encoded_total, raw_total), + @sizeOf(lz4.HashTable), + self.compression_output.len, + }, + ); +} + +/// Iterate fixed-size page chunks without allocating an index table. +fn pages(self: *const PageCompression) PageIterator { + return .{ + .data = self.data, + .page_size = self.opts.@"page-size", + }; +} + +const PageIterator = struct { + data: []const u8, + page_size: usize, + offset: usize = 0, + + fn next(self: *PageIterator) ?[]const u8 { + if (self.offset >= self.data.len) return null; + const len = @min(self.page_size, self.data.len - self.offset); + const end = self.offset + len; + defer self.offset = end; + return self.data[self.offset..end]; + } +}; + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test PageCompression { + const testing = std.testing; + const impl: *PageCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "PageCompression store retains exact encoded allocations" { + const testing = std.testing; + const page_size = 1024; + const impl: *PageCompression = try .create(testing.allocator, .{ + .mode = .store, + .@"page-size" = page_size, + .@"retained-pages" = 2, + }); + defer impl.destroy(testing.allocator); + + impl.data = try testing.allocator.alloc(u8, 3 * page_size); + @memset(impl.data, 0); + impl.compression_output = try testing.allocator.alloc( + u8, + try lz4.compressBound(page_size), + ); + try impl.prepareStored(); + + try stepStore(impl); + try testing.expectEqual(@as(usize, 1), impl.stored_next); + for (impl.stored) |block| { + const encoded = block.?; + try testing.expect(encoded.len < page_size); + + var decoded: [page_size]u8 = undefined; + _ = try lz4.decompress(encoded, &decoded); + try testing.expect(std.mem.allEqual(u8, &decoded, 0)); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig index 108eaa0c..5176ce26 100644 --- a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig +++ b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig @@ -21,6 +21,10 @@ pub const Options = struct { /// The type of codepoint width calculation to use. mode: Mode = .clone, + /// Multiplier on the number of iterations each step runs. This is + /// useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + /// The size of the terminal. This affects benchmarking when /// dealing with soft line wrapping and the memory impact /// of page sizes. @@ -48,6 +52,22 @@ pub const Mode = enum { /// RenderState rather than a screen clone. render, + + /// Like render, but only the portion of the render state update + /// that requires holding a terminal lock (beginUpdate). The + /// deferred work (endUpdate) is excluded since it happens outside + /// of any locks. + @"render-locked", + + /// RenderState update with no changes to the terminal. This is + /// the common case for a renderer that is redrawing frames (e.g. + /// cursor blink, mouse movement) without terminal changes. + @"render-clean", + + /// RenderState update where a single row is dirty. This models the + /// common case of a shell prompt or TUI updating a small portion + /// of the screen between frames. + @"render-partial", }; pub fn create( @@ -79,6 +99,9 @@ pub fn benchmark(self: *ScreenClone) Benchmark { .noop => stepNoop, .clone => stepClone, .render => stepRender, + .@"render-locked" => stepRenderLocked, + .@"render-clean" => stepRenderClean, + .@"render-partial" => stepRenderPartial, }, .setupFn = setup, .teardownFn = teardown, @@ -178,16 +201,102 @@ fn stepRender(ptr: *anyopaque) Benchmark.Error!void { // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. - for (0..1000) |_| { + for (0..50_000 * @as(u64, self.opts.loops)) |_| { // Forces a full rebuild because it thinks our screen changed state.screen = .alternate; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; - std.mem.doNotOptimizeAway(state); + std.mem.doNotOptimizeAway(&state); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} + +fn stepRenderLocked(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We do this once out of the loop because a significant slowdown + // on the first run is allocation. After that first run, even with + // a full rebuild, it is much faster. Let's ignore that first run + // slowdown. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..50_000 * @as(u64, self.opts.loops)) |_| { + // Forces a full rebuild because it thinks our screen changed + state.screen = .alternate; + state.beginUpdate(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); // Note: we purposely do not free memory because we don't want // to benchmark that. We'll free when the benchmark exits. } } + +fn stepRenderClean(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Initial update so that subsequent updates are clean (nothing + // dirty, no rebuilds). + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..3_000_000 * @as(u64, self.opts.loops)) |_| { + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); + } +} + +fn stepRenderPartial(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Initial update so that subsequent updates are incremental. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // Grab a pin roughly in the middle of the active area that we + // dirty on every iteration to simulate a small screen update. + const pages = &self.terminal.screens.active.pages; + const pin = pages.pin(.{ .active = .{ + .x = 0, + .y = self.terminal.rows / 2, + } }).?; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..2_000_000 * @as(u64, self.opts.loops)) |_| { + // Mark a single row dirty. `update` clears this so each + // iteration rebuilds exactly one row. + pin.markDirty(); + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig new file mode 100644 index 00000000..40ec20f4 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig @@ -0,0 +1,343 @@ +//! Benchmarks cold-history compression and restoration on pages owned by a +//! live terminal. +//! +//! Unlike `page-compression`, which measures the standalone codec against raw +//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`. +//! Its timed operations therefore include the PageList state transition, +//! exact-sized encoded allocation, retained-mapping reclamation, and transparent +//! restoration through `PageList.Node.page`. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream. Parsing happens during setup +//! and is not part of the benchmark's timed region. Use the same saved corpus, +//! terminal dimensions, and scrollback limit when comparing revisions. In +//! particular, do not pipe a generator into this benchmark: generation and +//! pipe scheduling add noise which can overwhelm the operation being measured. +//! +//! The benchmark always operates on the primary screen's PageList. This keeps +//! the result focused on scrollback even if malformed or incomplete input +//! leaves the terminal in its alternate screen at end of file. `--max-scrollback` +//! is expressed in bytes and defaults to 10 MB. +//! +//! ## Modes +//! +//! * `noop` parses the corpus but performs no timed PageList operation. This is +//! the common process and setup baseline for the other modes. +//! * `compress` times one complete `compress` invocation. +//! * `incremental` reaches the same final representation through +//! `compress(.drain)`. It drains the candidate-bounded steps and final +//! no-work verification pass in the timed region, making cursor and repeated +//! traversal overhead directly comparable with `compress`. +//! * `restore` compresses cold history during setup, outside the timed region, +//! then visits every fully historical node through `Node.page`. That public +//! content-access boundary transparently restores compressed nodes. +//! * `report` performs one compression pass and prints compressed page count, +//! encoded ratio, and estimated resident-byte savings. It is intended for +//! inspecting a corpus rather than timing comparisons. +//! +//! A fully historical page is a node strictly before the node containing the +//! top of the active area. The boundary node is deliberately excluded because +//! it can contain both history and active rows. Pages intersecting the current +//! viewport are also excluded so visible contents remain resident. Normal +//! terminal execution compresses eligible pages incrementally after activity +//! becomes idle. The benchmark invokes PageList operations directly so its +//! timed regions exclude the production scheduler's idle delay and +//! renderer-thread coordination. +//! +//! ## Examples +//! +//! Build the benchmark in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the memory reduction for a saved VT corpus: +//! +//! ghostty-bench +scrollback-compression --mode=report \ +//! --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80 +//! +//! Compare PageList compression and restoration cost. Setup still contributes +//! to full process time, so use a sufficiently large corpus and compare against +//! `noop` with identical arguments: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt' +const ScrollbackCompression = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const PageList = terminalpkg.PageList; +const Terminal = terminalpkg.Terminal; + +const log = std.log.scoped(.@"scrollback-compression-bench"); + +opts: Options, +terminal: Terminal, + +pub const Options = struct { + /// Set by the shared CLI parser so the `data` string remains valid for the + /// lifetime of the benchmark implementation. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the PageList operation performed inside the timed benchmark. + mode: Mode = .compress, + + /// Dimensions used to construct the terminal which consumes the corpus. + /// These affect wrapping and therefore the number and contents of pages. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// Maximum primary-screen scrollback allocation in bytes. PageList rounds + /// this as required by its page allocation policy. + @"max-scrollback": usize = 10_000_000, + + /// Pre-generated VT corpus. `-` reads stdin, although a regular file is + /// strongly recommended so comparisons can reuse identical input bytes. + /// When unset, every mode operates on the initial empty terminal. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish process and setup overhead without a PageList operation. + noop, + + /// Compress every eligible fully historical page once. + compress, + + /// Compress every eligible page through bounded resumable steps. + incremental, + + /// Restore pages compressed outside the timed region. + restore, + + /// Compress once and print aggregate memory statistics. + report, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*ScrollbackCompression { + const ptr = try alloc.create(ScrollbackCompression); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .terminal = try .init(alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback = opts.@"max-scrollback", + }), + }; + return ptr; +} + +pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *ScrollbackCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .incremental => stepIncremental, + .restore => stepRestore, + .report => stepReport, + }, + .setupFn = setup, + }); +} + +/// Reset the terminal and consume the complete VT corpus before timing starts. +/// Restore mode also prepares its compressed representation here so its timed +/// step contains decoding and mapping writes, but not encoding. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.terminal.fullReset(); + + self.loadCorpus() catch |err| { + log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; + + if (self.opts.mode == .restore) { + _ = self.pages().compress(.full); + } +} + +/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and +/// terminal-stream benchmark. Parser and file IO costs remain in setup. +fn loadCorpus(self: *ScrollbackCompression) !void { + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; + var file_reader = data_file.reader(&read_buf); + const reader = &file_reader.interface; + + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = reader.readSliceShort(&buf) catch + return file_reader.err orelse error.ReadFailed; + if (n == 0) return; + stream.nextSlice(buf[0..n]); + } +} + +/// Return the primary screen because it is the terminal screen which owns +/// scrollback. A corpus ending in the alternate screen must not turn this into +/// an alternate-screen allocation benchmark. +fn pages(self: *ScrollbackCompression) *PageList { + return &self.terminal.screens.get(.primary).?.pages; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.full); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.drain); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(self.visitColdPages()); +} + +/// Visit all nodes which were eligible for the setup compression pass. +/// +/// `page` is intentionally used instead of inspecting the union payload so the +/// benchmark follows the same transparent restoration boundary as consumers. +/// Compressible nodes restore here; resident candidates which failed the +/// opportunistic pass are harmlessly visited through the same boundary. +fn visitColdPages(self: *ScrollbackCompression) usize { + const page_list = self.pages(); + const active_node = page_list.getTopLeft(.active).node; + var visited: usize = 0; + var node_ = page_list.pages.first; + while (node_) |node| : (node_ = node.next) { + if (node == active_node) break; + std.mem.doNotOptimizeAway(node.page()); + visited += 1; + } + return visited; +} + +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.full); + const memory = self.pages().memoryStats(); + + std.debug.print( + "scrollback-compression compressed={d} raw={d} " ++ + "encoded={d} ratio={d:.2}% savings={d}\n", + .{ + memory.compressed_pages, + memory.decommitted_raw_bytes, + memory.encoded_bytes, + percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ), + memory.estimatedSavings(), + }, + ); +} + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test ScrollbackCompression { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "ScrollbackCompression restores cold terminal pages" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .restore, + .@"terminal-rows" = 4, + // Standard-width pages hold 215 rows. Keeping that row capacity here + // makes this test corpus small while still producing cold history. + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + _ = impl.pages().compress(.full); + const compressed = impl.pages().memoryStats(); + try testing.expect(compressed.compressed_pages > 0); + try testing.expect(impl.visitColdPages() >= compressed.compressed_pages); + + // Restored historical pages are resident and therefore eligible for a + // later explicit pass. This also verifies that the benchmark traversal + // went through Node.page rather than merely inspecting page metadata. + _ = impl.pages().compress(.full); + const recompressed = impl.pages().memoryStats(); + try testing.expectEqual( + compressed.compressed_pages, + recompressed.compressed_pages, + ); +} + +test "ScrollbackCompression drains incremental compression steps" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .incremental, + .@"terminal-rows" = 4, + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + _ = impl.pages().compress(.drain); + const incremental = impl.pages().memoryStats(); + try testing.expect(incremental.compressed_pages > 0); + + // Restore the same pages and compare against the monolithic operation. + // Both paths should produce the same final storage representation. + _ = impl.visitColdPages(); + _ = impl.pages().compress(.full); + const monolithic = impl.pages().memoryStats(); + try testing.expectEqual(monolithic, incremental); +} diff --git a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig index 1cac656e..a5302005 100644 --- a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig +++ b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig @@ -2,15 +2,13 @@ //! handler from input to terminal state update. This is useful to //! test general throughput of VT parsing and handling. //! -//! Note that the handler used for this benchmark isn't the full -//! terminal handler, since that requires a significant amount of -//! state. This is a simplified version that only handles specific -//! terminal operations like printing characters. We should expand -//! this to include more operations to improve the accuracy of the -//! benchmark. +//! This uses the full readonly terminal stream handler +//! (terminal.TerminalStream) so every escape sequence updates real +//! terminal state (styles, cursor movement, erases, modes, etc.). +//! This closely mirrors the work done by the real IO thread. //! -//! It is a fairly broad benchmark that can be used to determine -//! if we need to optimize something more specific (e.g. the parser). +//! For more isolated measurements see the terminal-parser and +//! osc-parser benchmarks. const TerminalStream = @This(); const std = @import("std"); @@ -20,13 +18,12 @@ const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; -const Stream = terminalpkg.Stream(*Handler); +const Stream = terminalpkg.TerminalStream; const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, terminal: Terminal, -handler: Handler, stream: Stream, /// The file, opened in the setup function. @@ -61,14 +58,15 @@ pub fn create( .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), - .handler = .{ .t = &ptr.terminal }, - .stream = .init(&ptr.handler), + .stream = undefined, }; + ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); return ptr; } pub fn destroy(self: *TerminalStream, alloc: Allocator) void { + self.stream.deinit(); self.terminal.deinit(alloc); alloc.destroy(self); } @@ -114,11 +112,14 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; - var buf: [4096]u8 = undefined; + // This buffer size matches the read buffer size used by the + // real IO thread (see termio Exec.zig buffer_capacity) so that + // the benchmark exercises the stream with realistic chunk sizes. + var buf: [64 * 1024]u8 = undefined; while (true) { const n = r.readSliceShort(&buf) catch { log.warn("error reading data file err={?}", .{f_reader.err}); @@ -129,26 +130,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { } } -/// Implements the handler interface for the terminal.Stream. -/// We should expand this to include more operations to make -/// our benchmark more realistic. -const Handler = struct { - t: *Terminal, - - pub fn vt( - self: *Handler, - comptime action: Stream.Action.Tag, - value: Stream.Action.Value(action), - ) void { - switch (action) { - .print => self.t.print(value.cp) catch |err| { - log.warn("error processing benchmark print err={}", .{err}); - }, - else => {}, - } - } -}; - test TerminalStream { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/benchmark/cli.zig b/vendor/libghostty-vt/src/benchmark/cli.zig index 13f07077..dc4d7b8f 100644 --- a/vendor/libghostty-vt/src/benchmark/cli.zig +++ b/vendor/libghostty-vt/src/benchmark/cli.zig @@ -6,8 +6,12 @@ const cli = @import("../cli.zig"); /// benchmarks. View docs for each individual one in the predictably /// named files. pub const Action = enum { + @"apc-parser", @"codepoint-width", @"grapheme-break", + @"hyperlink-map", + @"page-compression", + @"scrollback-compression", @"screen-clone", @"terminal-parser", @"terminal-stream", @@ -24,7 +28,11 @@ pub const Action = enum { /// See TerminalStream for an example. pub fn Struct(comptime action: Action) type { return switch (action) { + .@"apc-parser" => @import("ApcParser.zig"), + .@"hyperlink-map" => @import("HyperlinkMap.zig"), .@"screen-clone" => @import("ScreenClone.zig"), + .@"page-compression" => @import("PageCompression.zig"), + .@"scrollback-compression" => @import("ScrollbackCompression.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/vendor/libghostty-vt/src/benchmark/main.zig b/vendor/libghostty-vt/src/benchmark/main.zig index 5673044f..f22891c7 100644 --- a/vendor/libghostty-vt/src/benchmark/main.zig +++ b/vendor/libghostty-vt/src/benchmark/main.zig @@ -4,9 +4,12 @@ pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); +pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); +pub const PageCompression = @import("PageCompression.zig"); +pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/vendor/libghostty-vt/src/config/Config.zig b/vendor/libghostty-vt/src/config/Config.zig index 66b8c605..d6a038d9 100644 --- a/vendor/libghostty-vt/src/config/Config.zig +++ b/vendor/libghostty-vt/src/config/Config.zig @@ -51,7 +51,6 @@ const terminal = struct { const color = @import("../terminal/color.zig"); const selection_codepoints = @import("../terminal/selection_codepoints.zig"); const style = @import("../terminal/style.zig"); - const x11_color = @import("../terminal/x11_color.zig"); }; const log = std.log.scoped(.config); @@ -1374,10 +1373,17 @@ input: RepeatableReadableIO = .{}, /// When this limit is reached, the oldest lines are removed from the /// scrollback. /// -/// Scrollback currently exists completely in memory. This means that the -/// larger this value, the larger potential memory usage. Scrollback is -/// allocated lazily up to this limit, so if you set this to a very large -/// value, it will not immediately consume a lot of memory. +/// Scrollback is stored in memory and allocated lazily up to this limit, so +/// setting a very large limit does not immediately consume that amount of +/// memory. On supported systems with scrollback compression enabled, Ghostty +/// attempts to compress fully historical pages which are not currently visible +/// while the terminal is idle. This can reduce physical memory usage, depending +/// on the contents of the scrollback. +/// +/// This limit always measures the uncompressed logical size of the terminal +/// pages. Compression does not allow Ghostty to retain more history than the +/// configured limit. Accessing compressed history restores it transparently +/// and may increase the terminal's physical memory usage again. /// /// This size is per terminal surface, not for the entire application. /// @@ -1385,7 +1391,31 @@ input: RepeatableReadableIO = .{}, /// This is a future planned feature. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit": usize = 10_000_000, // 10MB +@"scrollback-limit": usize = 50_000_000, // 50MB + +/// Whether to compress scrollback pages while the terminal is idle. +/// +/// Ghostty does its best to only compress when idle and decompress +/// as needed. This means that compression doesn't lower IO throughput. +/// We recommend you keep it on. +/// +/// The scrollback limit remains an uncompressed logical limit regardless of +/// this setting, so disabling compression can increase physical memory usage +/// but does not change how much history is retained. +/// +/// Text-heavy terminal history generally compresses to approximately 10% to +/// 30% of its uncompressed page memory, corresponding to a 70% to 90% reduction +/// in physical memory for pages which are compressed. Compression savings are +/// content-dependent. +/// +/// Note that the way Ghostty works is that we compress and discard the +/// physical/resident memory but we retain virtual mappings. You will not +/// see a decrease in virtual memory usage, but you will see a decrease +/// in physical/memory usage. +/// +/// Changing this at runtime affects future compression work. Pages which are +/// already compressed remain compressed until their contents are accessed. +@"scrollback-compression": bool = true, /// Control when the scrollbar is shown to scroll the scrollback buffer. /// @@ -3657,6 +3687,14 @@ else /// which is the old style. @"gtk-wide-tabs": bool = true, +/// If `true` (default), then two-finger horizontal scrolling on a touchpad +/// will switch between tabs. Scrolling left goes to the next tab and +/// scrolling right goes to the previous tab. Set this to `false` to +/// disable this behavior. +/// +/// Available since 1.4.0. +@"gtk-horizontal-tab-scroll": bool = true, + /// Custom CSS files to be loaded. /// /// GTK CSS documentation can be found at the following links: @@ -5433,16 +5471,8 @@ pub const Color = struct { pub fn parseCLI(input_: ?[]const u8) !Color { const input = input_ orelse return error.ValueRequired; - // Trim any whitespace before processing - const trimmed = std.mem.trim(u8, input, " \t"); - - if (terminal.x11_color.map.get(trimmed)) |rgb| return .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }; - - return fromHex(trimmed); + const rgb: terminal.color.RGB = terminal.color.RGB.parse(input) catch return error.InvalidValue; + return .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; } /// Deep copy of the struct. Required by Config. @@ -5473,48 +5503,15 @@ pub const Color = struct { ) catch error.OutOfMemory; } - /// fromHex parses a color from a hex value such as #RRGGBB. The "#" - /// is optional. - pub fn fromHex(input: []const u8) !Color { - // Trim the beginning '#' if it exists - const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; - if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; - - // Expand short hex values to full hex values - const rgb: []const u8 = if (trimmed.len == 3) &.{ - trimmed[0], trimmed[0], - trimmed[1], trimmed[1], - trimmed[2], trimmed[2], - } else trimmed; - - // Parse the colors two at a time. - var result: Color = undefined; - comptime var i: usize = 0; - inline while (i < 6) : (i += 2) { - const v: u8 = - ((try std.fmt.charToDigit(rgb[i], 16)) * 16) + - try std.fmt.charToDigit(rgb[i + 1], 16); - - @field(result, switch (i) { - 0 => "r", - 2 => "g", - 4 => "b", - else => unreachable, - }) = v; - } - - return result; - } - - test "fromHex" { + test "parseCLI hex" { const testing = std.testing; - try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.fromHex("#000000")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); - try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); - try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); - try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); + try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.parseCLI("#000000")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("#0A0B0C")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("0A0B0C")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFFFFF")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.parseCLI("#345")); } test "parseCLI from name" { @@ -5860,20 +5857,12 @@ pub const Palette = struct { input: ?[]const u8, ) !void { const value = input orelse return error.ValueRequired; - const eqlIdx = std.mem.indexOf(u8, value, "=") orelse - return error.InvalidValue; - - // Parse the key part (trim whitespace) - const key = try std.fmt.parseInt( - u8, - std.mem.trim(u8, value[0..eqlIdx], " \t"), - 0, - ); - - // Parse the color part (Color.parseCLI will handle whitespace) - const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); - self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; - self.mask.set(key); + const entry = terminal.color.parsePaletteEntry(value) catch |err| switch (err) { + error.Overflow => return error.Overflow, + error.InvalidFormat => return error.InvalidValue, + }; + self.value[entry.index] = entry.color; + self.mask.set(entry.index); } /// Deep copy of the struct. Required by Config. diff --git a/vendor/libghostty-vt/src/font/CodepointResolver.zig b/vendor/libghostty-vt/src/font/CodepointResolver.zig index a4f13c29..90fe2491 100644 --- a/vendor/libghostty-vt/src/font/CodepointResolver.zig +++ b/vendor/libghostty-vt/src/font/CodepointResolver.zig @@ -24,7 +24,7 @@ const Face = font.Face; const Glyph = font.Glyph; const Library = font.Library; const Presentation = font.Presentation; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const SpriteFace = font.SpriteFace; const Style = font.Style; diff --git a/vendor/libghostty-vt/src/font/Glyph.zig b/vendor/libghostty-vt/src/font/Glyph.zig index f9937027..62c44e22 100644 --- a/vendor/libghostty-vt/src/font/Glyph.zig +++ b/vendor/libghostty-vt/src/font/Glyph.zig @@ -1,6 +1,9 @@ //! Glyph is a single loaded glyph for a face. const Glyph = @This(); +const std = @import("std"); +const Metrics = @import("Metrics.zig"); + /// width of glyph in pixels width: u32, @@ -17,3 +20,611 @@ offset_y: i32, /// be normalized to be between 0 and 1 prior to use in shaders. atlas_x: u32, atlas_y: u32, + +/// The size and position of a glyph. +pub const Size = struct { + width: f64, + height: f64, + x: f64, + y: f64, +}; + +/// Metrics describing the authored glyph coordinate space. +pub const DesignMetrics = struct { + /// Units-per-em for outline/design coordinates. + units_per_em: u32, + + /// Authored advance width in design units. + advance_width: u32, + + /// Authored line height in design units. + line_height: u32, +}; + +/// Additional options for rendering glyphs. +pub const RenderOptions = struct { + /// The metrics that are defining the grid layout. These are usually + /// the metrics of the primary font face. The grid metrics are used + /// by the font face to better layout the glyph in situations where + /// the font is not exactly the same size as the grid. + grid_metrics: Metrics, + + /// The number of grid cells this glyph will take up. This can be used + /// optionally by the rasterizer to better layout the glyph. + cell_width: ?u2 = null, + + /// Constraint and alignment properties for the glyph. The rasterizer + /// should call the `constrain` function on this with the original size + /// and bearings of the glyph to get remapped values that the glyph + /// should be scaled/moved to. + constraint: Constraint = .none, + + /// The number of cells, horizontally that the glyph is free to take up + /// when resized and aligned by `constraint`. This is usually 1, but if + /// there's whitespace to the right of the cell then it can be 2. + constraint_width: u2 = 1, + + /// Thicken the glyph. This draws the glyph with a thicker stroke width. + /// This is purely an aesthetic setting. + /// + /// This only works with CoreText currently. + thicken: bool = false, + + /// "Strength" of the thickening, between `0` and `255`. + /// Only has an effect when `thicken` is enabled. + /// + /// `0` does not correspond to *no* thickening, + /// just the *lightest* thickening available. + /// + /// CoreText only. + thicken_strength: u8 = 255, + + /// See the `constraint` field. + pub const Constraint = struct { + /// Don't constrain the glyph in any way. + pub const none: Constraint = .{}; + + /// Sizing rule. + size: Constraint.Size = .none, + + /// Vertical alignment rule. + align_vertical: Align = .none, + /// Horizontal alignment rule. + align_horizontal: Align = .none, + + /// Top padding when resizing. + pad_top: f64 = 0.0, + /// Left padding when resizing. + pad_left: f64 = 0.0, + /// Right padding when resizing. + pad_right: f64 = 0.0, + /// Bottom padding when resizing. + pad_bottom: f64 = 0.0, + + // Size and bearings of the glyph relative + // to the bounding box of its scale group. + relative_width: f64 = 1.0, + relative_height: f64 = 1.0, + relative_x: f64 = 0.0, + relative_y: f64 = 0.0, + + /// Maximum aspect ratio (width/height) to allow when stretching. + max_xy_ratio: ?f64 = null, + + /// Maximum number of cells horizontally to use. + max_constraint_width: u2 = 2, + + /// What to use as the height metric when constraining the glyph and + /// the constraint width is 1, + height: Height = .cell, + + pub const Size = enum { + /// Don't change the size of this glyph. + none, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. + fit, + /// Scale the glyph up or down to exactly match the bounds, + /// preserving aspect ratio. + cover, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. If the glyph doesn't cover a + /// single cell, scale up. If the glyph exceeds a single + /// cell but is within the bounds, do nothing. + /// (Nerd Font specific rule.) + fit_cover1, + /// Stretch the glyph to exactly fit the bounds in both + /// directions, disregarding aspect ratio. + stretch, + }; + + pub const Align = enum { + /// Don't move the glyph on this axis. + none, + /// Move the glyph so that its leading (bottom/left) + /// edge aligns with the leading edge of the axis. + start, + /// Move the glyph so that its trailing (top/right) + /// edge aligns with the trailing edge of the axis. + end, + /// Move the glyph so that it is centered on this axis. + center, + /// Move the glyph so that it is centered on this axis, + /// but always with respect to the first cell even for + /// multi-cell constraints. (Nerd Font specific rule.) + center1, + }; + + pub const Height = enum { + /// Use the full line height of the primary face for + /// constraining this glyph. + cell, + /// Use the icon height from the grid metrics for + /// constraining this glyph. Unlike `cell`, the value of + /// this height depends on both the constraint width and the + /// affected by the `adjust-icon-height` config option. + icon, + }; + + /// Returns true if the constraint does anything. If it doesn't, + /// because it neither sizes nor positions the glyph, then this + /// returns false. + pub inline fn doesAnything(self: Constraint) bool { + return self.size != .none or + self.align_horizontal != .none or + self.align_vertical != .none; + } + + /// Apply this constraint to the provided glyph + /// size, given the available width and height. + pub fn constrain( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + /// Number of cells horizontally available for this glyph. + constraint_width: u2, + ) Glyph.Size { + if (!self.doesAnything()) return glyph; + + switch (self.size) { + .stretch => { + // Stretched glyphs are usually meant to align across cell + // boundaries, which works best if they're scaled and + // aligned to the grid rather than the face. This is most + // easily done by inserting this little fib in the metrics. + var m = metrics; + m.face_width = @floatFromInt(m.cell_width); + m.face_height = @floatFromInt(m.cell_height); + m.face_y = 0.0; + + // Negative padding for stretched glyphs is a band-aid to + // avoid gaps due to pixel rounding, but at the cost of + // unsightly overlap artifacts. Since we scale and align to + // the grid rather than the face, we don't need it. + var c = self; + c.pad_bottom = @max(0, c.pad_bottom); + c.pad_top = @max(0, c.pad_top); + c.pad_left = @max(0, c.pad_left); + c.pad_right = @max(0, c.pad_right); + + return c.constrainInner(glyph, m, constraint_width); + }, + else => return self.constrainInner(glyph, metrics, constraint_width), + } + } + + fn constrainInner( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + constraint_width: u2, + ) Glyph.Size { + // For extra wide font faces, never stretch glyphs across two cells. + // This mirrors font_patcher. + const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width); + + // The bounding box for the glyph's scale group. + // Scaling and alignment rules are calculated for + // this box and then applied to the glyph. + var group: Glyph.Size = group: { + const group_width = glyph.width / self.relative_width; + const group_height = glyph.height / self.relative_height; + break :group .{ + .width = group_width, + .height = group_height, + .x = glyph.x - (group_width * self.relative_x), + .y = glyph.y - (group_height * self.relative_y), + }; + }; + + // Apply prescribed scaling, preserving the + // center bearings of the group bounding box + const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); + const center_x = group.x + (group.width / 2); + const center_y = group.y + (group.height / 2); + group.width *= width_factor; + group.height *= height_factor; + group.x = center_x - (group.width / 2); + group.y = center_y - (group.height / 2); + + // NOTE: font_patcher jumps through a lot of hoops at this + // point to ensure that the glyph remains within the target + // bounding box after rounding to font definition units. + // This is irrelevant here as we're not rounding, we're + // staying in f64 and heading straight to rendering. + + // Apply prescribed alignment + group.y = self.aligned_y(group, metrics); + group.x = self.aligned_x(group, metrics, min_constraint_width); + + // Transfer the scaling and alignment back to the glyph and return. + return .{ + .width = width_factor * glyph.width, + .height = height_factor * glyph.height, + .x = group.x + (group.width * self.relative_x), + .y = group.y + (group.height * self.relative_y), + }; + } + + /// Return width and height scaling factors for this scaling group. + fn scale_factors( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) struct { f64, f64 } { + if (self.size == .none) { + return .{ 1.0, 1.0 }; + } + + const multi_cell = (min_constraint_width > 1); + + const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); + const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); + + const target_width = pad_width_factor * metrics.face_width; + const target_height = pad_height_factor * switch (self.height) { + .cell => metrics.face_height, + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. + .icon => if (multi_cell) + metrics.icon_height + else + metrics.icon_height_single, + }; + + var width_factor = target_width / group.width; + var height_factor = target_height / group.height; + + switch (self.size) { + .none => unreachable, + .fit => { + // Scale down to fit if needed + height_factor = @min(1, width_factor, height_factor); + width_factor = height_factor; + }, + .cover => { + // Scale to cover + height_factor = @min(width_factor, height_factor); + width_factor = height_factor; + }, + .fit_cover1 => { + // Scale down to fit or up to cover at least one cell + // NOTE: This is similar to font_patcher's "pa" mode, + // however, font_patcher will only do the upscaling + // part if the constraint width is 1, resulting in + // some icons becoming smaller when the constraint + // width increases. You'd see icons shrinking when + // opening up a space after them. This makes no + // sense, so we've fixed the rule such that these + // icons are scaled to the same size for multi-cell + // constraints as they would be for single-cell. + height_factor = @min(width_factor, height_factor); + if (multi_cell and (height_factor > 1)) { + // Call back into this function with + // constraint width 1 to get single-cell scale + // factors. We use the height factor as width + // could have been modified by max_xy_ratio. + _, const single_height_factor = self.scale_factors(group, metrics, 1); + height_factor = @max(1, single_height_factor); + } + width_factor = height_factor; + }, + .stretch => {}, + } + + // Reduce aspect ratio if required + if (self.max_xy_ratio) |ratio| { + if (group.width * width_factor > group.height * height_factor * ratio) { + width_factor = group.height * height_factor * ratio / group.width; + } + } + + return .{ width_factor, height_factor }; + } + + /// Return vertical bearing for aligning this group + fn aligned_y( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + ) f64 { + if ((self.size == .none) and (self.align_vertical == .none)) { + // If we don't have any constraints affecting the vertical axis, + // we don't touch vertical alignment. + return group.y; + } + // We use face_height and offset by face_y, rather than + // using cell_height directly, to account for the asymmetry + // of the pixel cell around the face (a consequence of + // aligning the baseline with a pixel boundary rather than + // vertically centering the face). + const pad_bottom_dy = self.pad_bottom * metrics.face_height; + const pad_top_dy = self.pad_top * metrics.face_height; + const start_y = metrics.face_y + pad_bottom_dy; + const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); + const center_y = (start_y + end_y) / 2; + return switch (self.align_vertical) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the group doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. If + // the group is too high we fall back to centering, though if we + // hit the .none prong we always have self.size != .none, so + // this should never happen. + .none => if (end_y < start_y) + center_y + else + @max(start_y, @min(group.y, end_y)), + .start => start_y, + .end => end_y, + .center, .center1 => center_y, + }; + } + + /// Return horizontal bearing for aligning this group + fn aligned_x( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) f64 { + if ((self.size == .none) and (self.align_horizontal == .none)) { + // If we don't have any constraints affecting the horizontal + // axis, we don't touch horizontal alignment. + return group.x; + } + // For multi-cell constraints, we align relative to the span + // from the left edge of the first cell to the right edge of + // the last face cell assuming it's left-aligned within the + // rounded and adjusted pixel cell. Any horizontal offset to + // center the face within the grid cell is the responsibility + // of the backend-specific rendering code, and should be done + // after applying constraints. + const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); + const pad_left_dx = self.pad_left * metrics.face_width; + const pad_right_dx = self.pad_right * metrics.face_width; + const start_x = pad_left_dx; + const end_x = full_face_span - group.width - pad_right_dx; + return switch (self.align_horizontal) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the glyph doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. The + // left-side bound has priority if the group is too wide, though + // if we hit the .none prong we always have self.size != .none, + // so this should never happen. + .none => @max(start_x, @min(group.x, end_x)), + .start => start_x, + .end => @max(start_x, end_x), + .center => @max(start_x, (start_x + end_x) / 2), + // NOTE: .center1 implements the font_patcher rule of centering + // in the first cell even for multi-cell constraints. Since glyphs + // are not allowed to protrude to the left, this results in the + // left-alignment like .start when the glyph is wider than a cell. + .center1 => center1: { + const end1_x = metrics.face_width - group.width - pad_right_dx; + break :center1 @max(start_x, (start_x + end1_x) / 2); + }, + }; + } + }; +}; + +test "Constraints" { + const comparison = @import("../datastruct/comparison.zig"); + const getConstraint = @import("nerd_font_attributes.zig").getConstraint; + const GlyphSize = Size; + + // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. + + // Define grid metrics (matches font-family = JetBrains Mono) + const metrics: Metrics = .{ + .cell_width = 10, + .cell_height = 22, + .cell_baseline = 5, + .underline_position = 19, + .underline_thickness = 1, + .strikethrough_position = 12, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = 22, + .icon_height = 21.12, + .icon_height_single = 44.48 / 3.0, + .face_width = 9.6, + .face_height = 21.12, + .face_y = 0.2, + }; + + // ASCII (no constraint). + { + const constraint: RenderOptions.Constraint = .none; + + // BBox of 'x' from JetBrains Mono. + const glyph_x: GlyphSize = .{ + .width = 6.784, + .height = 15.28, + .x = 1.408, + .y = 4.84, + }; + + // Any constraint width: do nothing. + inline for (.{ 1, 2 }) |constraint_width| { + try comparison.expectApproxEqual( + glyph_x, + constraint.constrain(glyph_x, metrics, constraint_width), + ); + } + } + + // Symbol (same constraint as hardcoded in Renderer.addGlyph). + { + const constraint: RenderOptions.Constraint = .{ .size = .fit }; + + // BBox of '■' (0x25A0 black square) from Iosevka. + // NOTE: This glyph is designed to span two cells. + const glyph_25A0: GlyphSize = .{ + .width = 10.272, + .height = 10.272, + .x = 2.864, + .y = 5.304, + }; + + // Constraint width 1: scale down and shift to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = metrics.face_width, + .height = metrics.face_width, + .x = 0, + .y = 5.64, + }, + constraint.constrain(glyph_25A0, metrics, 1), + ); + + // Constraint width 2: do nothing. + try comparison.expectApproxEqual( + glyph_25A0, + constraint.constrain(glyph_25A0, metrics, 2), + ); + } + + // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). + { + const constraint: RenderOptions.Constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + .pad_left = 0.025, + .pad_right = 0.025, + }; + + // BBox of '🥸' (0x1F978) from Apple Color Emoji. + const glyph_1F978: GlyphSize = .{ + .width = 20, + .height = 20, + .x = 0.46, + .y = 1, + }; + + // Constraint width 2: scale to cover two cells with padding, center; + try comparison.expectApproxEqual( + GlyphSize{ + .width = 18.72, + .height = 18.72, + .x = 0.44, + .y = 1.4, + }, + constraint.constrain(glyph_1F978, metrics, 2), + ); + } + + // Nerd Font default. + { + const constraint = getConstraint(0xea61).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.fit_cover1, constraint.size); + try std.testing.expectEqual(.icon, constraint.height); + try std.testing.expectEqual(.center1, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. + // NOTE: This icon is part of a group, so the + // constraint applies to a larger bounding box. + const glyph_EA61: GlyphSize = .{ + .width = 9.015625, + .height = 13.015625, + .x = 3.015625, + .y = 3.76525, + }; + + // Constraint width 1: scale and shift group to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = 7.2125, + .height = 10.4125, + .x = 0.8125, + .y = 5.950695224719102, + }, + constraint.constrain(glyph_EA61, metrics, 1), + ); + + // Constraint width 2: no scaling; left-align and vertically center group. + try comparison.expectApproxEqual( + GlyphSize{ + .width = glyph_EA61.width, + .height = glyph_EA61.height, + .x = 1.015625, + .y = 4.7483690308988775, + }, + constraint.constrain(glyph_EA61, metrics, 2), + ); + } + + // Nerd Font stretch. + { + const constraint = getConstraint(0xe0c0).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.stretch, constraint.size); + try std.testing.expectEqual(.cell, constraint.height); + try std.testing.expectEqual(.start, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. + const glyph_E0C0: GlyphSize = .{ + .width = 16.796875, + .height = 16.46875, + .x = -0.796875, + .y = 1.7109375, + }; + + // Constraint width 1: stretch and position to exactly cover one cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 1), + ); + + // Constraint width 1: stretch and position to exactly cover two cells. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(2 * metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 2), + ); + } +} diff --git a/vendor/libghostty-vt/src/font/Metrics.zig b/vendor/libghostty-vt/src/font/Metrics.zig index a72cb7be..60a300b0 100644 --- a/vendor/libghostty-vt/src/font/Metrics.zig +++ b/vendor/libghostty-vt/src/font/Metrics.zig @@ -560,6 +560,8 @@ pub const Modifier = union(enum) { } test "formatConfig percent" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); @@ -571,6 +573,8 @@ pub const Modifier = union(enum) { } test "formatConfig absolute" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); diff --git a/vendor/libghostty-vt/src/font/SharedGrid.zig b/vendor/libghostty-vt/src/font/SharedGrid.zig index 5fd729b3..18870aab 100644 --- a/vendor/libghostty-vt/src/font/SharedGrid.zig +++ b/vendor/libghostty-vt/src/font/SharedGrid.zig @@ -33,7 +33,7 @@ const Library = font.Library; const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const log = std.log.scoped(.font_shared_grid); diff --git a/vendor/libghostty-vt/src/font/face.zig b/vendor/libghostty-vt/src/font/face.zig index d77253ad..aeb7280c 100644 --- a/vendor/libghostty-vt/src/font/face.zig +++ b/vendor/libghostty-vt/src/font/face.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); const options = @import("main.zig").options; -const Metrics = @import("main.zig").Metrics; const config = @import("../config.zig"); const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); @@ -94,407 +93,6 @@ pub const Variation = struct { }; }; -/// The size and position of a glyph. -pub const GlyphSize = struct { - width: f64, - height: f64, - x: f64, - y: f64, -}; - -/// Additional options for rendering glyphs. -pub const RenderOptions = struct { - /// The metrics that are defining the grid layout. These are usually - /// the metrics of the primary font face. The grid metrics are used - /// by the font face to better layout the glyph in situations where - /// the font is not exactly the same size as the grid. - grid_metrics: Metrics, - - /// The number of grid cells this glyph will take up. This can be used - /// optionally by the rasterizer to better layout the glyph. - cell_width: ?u2 = null, - - /// Constraint and alignment properties for the glyph. The rasterizer - /// should call the `constrain` function on this with the original size - /// and bearings of the glyph to get remapped values that the glyph - /// should be scaled/moved to. - constraint: Constraint = .none, - - /// The number of cells, horizontally that the glyph is free to take up - /// when resized and aligned by `constraint`. This is usually 1, but if - /// there's whitespace to the right of the cell then it can be 2. - constraint_width: u2 = 1, - - /// Thicken the glyph. This draws the glyph with a thicker stroke width. - /// This is purely an aesthetic setting. - /// - /// This only works with CoreText currently. - thicken: bool = false, - - /// "Strength" of the thickening, between `0` and `255`. - /// Only has an effect when `thicken` is enabled. - /// - /// `0` does not correspond to *no* thickening, - /// just the *lightest* thickening available. - /// - /// CoreText only. - thicken_strength: u8 = 255, - - /// See the `constraint` field. - pub const Constraint = struct { - /// Don't constrain the glyph in any way. - pub const none: Constraint = .{}; - - /// Sizing rule. - size: Size = .none, - - /// Vertical alignment rule. - align_vertical: Align = .none, - /// Horizontal alignment rule. - align_horizontal: Align = .none, - - /// Top padding when resizing. - pad_top: f64 = 0.0, - /// Left padding when resizing. - pad_left: f64 = 0.0, - /// Right padding when resizing. - pad_right: f64 = 0.0, - /// Bottom padding when resizing. - pad_bottom: f64 = 0.0, - - // Size and bearings of the glyph relative - // to the bounding box of its scale group. - relative_width: f64 = 1.0, - relative_height: f64 = 1.0, - relative_x: f64 = 0.0, - relative_y: f64 = 0.0, - - /// Maximum aspect ratio (width/height) to allow when stretching. - max_xy_ratio: ?f64 = null, - - /// Maximum number of cells horizontally to use. - max_constraint_width: u2 = 2, - - /// What to use as the height metric when constraining the glyph and - /// the constraint width is 1, - height: Height = .cell, - - pub const Size = enum { - /// Don't change the size of this glyph. - none, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. - fit, - /// Scale the glyph up or down to exactly match the bounds, - /// preserving aspect ratio. - cover, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. If the glyph doesn't cover a - /// single cell, scale up. If the glyph exceeds a single - /// cell but is within the bounds, do nothing. - /// (Nerd Font specific rule.) - fit_cover1, - /// Stretch the glyph to exactly fit the bounds in both - /// directions, disregarding aspect ratio. - stretch, - }; - - pub const Align = enum { - /// Don't move the glyph on this axis. - none, - /// Move the glyph so that its leading (bottom/left) - /// edge aligns with the leading edge of the axis. - start, - /// Move the glyph so that its trailing (top/right) - /// edge aligns with the trailing edge of the axis. - end, - /// Move the glyph so that it is centered on this axis. - center, - /// Move the glyph so that it is centered on this axis, - /// but always with respect to the first cell even for - /// multi-cell constraints. (Nerd Font specific rule.) - center1, - }; - - pub const Height = enum { - /// Use the full line height of the primary face for - /// constraining this glyph. - cell, - /// Use the icon height from the grid metrics for - /// constraining this glyph. Unlike `cell`, the value of - /// this height depends on both the constraint width and the - /// affected by the `adjust-icon-height` config option. - icon, - }; - - /// Returns true if the constraint does anything. If it doesn't, - /// because it neither sizes nor positions the glyph, then this - /// returns false. - pub inline fn doesAnything(self: Constraint) bool { - return self.size != .none or - self.align_horizontal != .none or - self.align_vertical != .none; - } - - /// Apply this constraint to the provided glyph - /// size, given the available width and height. - pub fn constrain( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - /// Number of cells horizontally available for this glyph. - constraint_width: u2, - ) GlyphSize { - if (!self.doesAnything()) return glyph; - - switch (self.size) { - .stretch => { - // Stretched glyphs are usually meant to align across cell - // boundaries, which works best if they're scaled and - // aligned to the grid rather than the face. This is most - // easily done by inserting this little fib in the metrics. - var m = metrics; - m.face_width = @floatFromInt(m.cell_width); - m.face_height = @floatFromInt(m.cell_height); - m.face_y = 0.0; - - // Negative padding for stretched glyphs is a band-aid to - // avoid gaps due to pixel rounding, but at the cost of - // unsightly overlap artifacts. Since we scale and align to - // the grid rather than the face, we don't need it. - var c = self; - c.pad_bottom = @max(0, c.pad_bottom); - c.pad_top = @max(0, c.pad_top); - c.pad_left = @max(0, c.pad_left); - c.pad_right = @max(0, c.pad_right); - - return c.constrainInner(glyph, m, constraint_width); - }, - else => return self.constrainInner(glyph, metrics, constraint_width), - } - } - - fn constrainInner( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - constraint_width: u2, - ) GlyphSize { - // For extra wide font faces, never stretch glyphs across two cells. - // This mirrors font_patcher. - const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) - 1 - else - @min(self.max_constraint_width, constraint_width); - - // The bounding box for the glyph's scale group. - // Scaling and alignment rules are calculated for - // this box and then applied to the glyph. - var group: GlyphSize = group: { - const group_width = glyph.width / self.relative_width; - const group_height = glyph.height / self.relative_height; - break :group .{ - .width = group_width, - .height = group_height, - .x = glyph.x - (group_width * self.relative_x), - .y = glyph.y - (group_height * self.relative_y), - }; - }; - - // Apply prescribed scaling, preserving the - // center bearings of the group bounding box - const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); - const center_x = group.x + (group.width / 2); - const center_y = group.y + (group.height / 2); - group.width *= width_factor; - group.height *= height_factor; - group.x = center_x - (group.width / 2); - group.y = center_y - (group.height / 2); - - // NOTE: font_patcher jumps through a lot of hoops at this - // point to ensure that the glyph remains within the target - // bounding box after rounding to font definition units. - // This is irrelevant here as we're not rounding, we're - // staying in f64 and heading straight to rendering. - - // Apply prescribed alignment - group.y = self.aligned_y(group, metrics); - group.x = self.aligned_x(group, metrics, min_constraint_width); - - // Transfer the scaling and alignment back to the glyph and return. - return .{ - .width = width_factor * glyph.width, - .height = height_factor * glyph.height, - .x = group.x + (group.width * self.relative_x), - .y = group.y + (group.height * self.relative_y), - }; - } - - /// Return width and height scaling factors for this scaling group. - fn scale_factors( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) struct { f64, f64 } { - if (self.size == .none) { - return .{ 1.0, 1.0 }; - } - - const multi_cell = (min_constraint_width > 1); - - const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); - const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); - - const target_width = pad_width_factor * metrics.face_width; - const target_height = pad_height_factor * switch (self.height) { - .cell => metrics.face_height, - // Like font-patcher, the icon constraint height depends on the - // constraint width. Unlike font-patcher, the multi-cell - // icon_height may be different from face_height due to the - // `adjust-icon-height` config option. - .icon => if (multi_cell) - metrics.icon_height - else - metrics.icon_height_single, - }; - - var width_factor = target_width / group.width; - var height_factor = target_height / group.height; - - switch (self.size) { - .none => unreachable, - .fit => { - // Scale down to fit if needed - height_factor = @min(1, width_factor, height_factor); - width_factor = height_factor; - }, - .cover => { - // Scale to cover - height_factor = @min(width_factor, height_factor); - width_factor = height_factor; - }, - .fit_cover1 => { - // Scale down to fit or up to cover at least one cell - // NOTE: This is similar to font_patcher's "pa" mode, - // however, font_patcher will only do the upscaling - // part if the constraint width is 1, resulting in - // some icons becoming smaller when the constraint - // width increases. You'd see icons shrinking when - // opening up a space after them. This makes no - // sense, so we've fixed the rule such that these - // icons are scaled to the same size for multi-cell - // constraints as they would be for single-cell. - height_factor = @min(width_factor, height_factor); - if (multi_cell and (height_factor > 1)) { - // Call back into this function with - // constraint width 1 to get single-cell scale - // factors. We use the height factor as width - // could have been modified by max_xy_ratio. - _, const single_height_factor = self.scale_factors(group, metrics, 1); - height_factor = @max(1, single_height_factor); - } - width_factor = height_factor; - }, - .stretch => {}, - } - - // Reduce aspect ratio if required - if (self.max_xy_ratio) |ratio| { - if (group.width * width_factor > group.height * height_factor * ratio) { - width_factor = group.height * height_factor * ratio / group.width; - } - } - - return .{ width_factor, height_factor }; - } - - /// Return vertical bearing for aligning this group - fn aligned_y( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - ) f64 { - if ((self.size == .none) and (self.align_vertical == .none)) { - // If we don't have any constraints affecting the vertical axis, - // we don't touch vertical alignment. - return group.y; - } - // We use face_height and offset by face_y, rather than - // using cell_height directly, to account for the asymmetry - // of the pixel cell around the face (a consequence of - // aligning the baseline with a pixel boundary rather than - // vertically centering the face). - const pad_bottom_dy = self.pad_bottom * metrics.face_height; - const pad_top_dy = self.pad_top * metrics.face_height; - const start_y = metrics.face_y + pad_bottom_dy; - const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); - const center_y = (start_y + end_y) / 2; - return switch (self.align_vertical) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the group doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. If - // the group is too high we fall back to centering, though if we - // hit the .none prong we always have self.size != .none, so - // this should never happen. - .none => if (end_y < start_y) - center_y - else - @max(start_y, @min(group.y, end_y)), - .start => start_y, - .end => end_y, - .center, .center1 => center_y, - }; - } - - /// Return horizontal bearing for aligning this group - fn aligned_x( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) f64 { - if ((self.size == .none) and (self.align_horizontal == .none)) { - // If we don't have any constraints affecting the horizontal - // axis, we don't touch horizontal alignment. - return group.x; - } - // For multi-cell constraints, we align relative to the span - // from the left edge of the first cell to the right edge of - // the last face cell assuming it's left-aligned within the - // rounded and adjusted pixel cell. Any horizontal offset to - // center the face within the grid cell is the responsibility - // of the backend-specific rendering code, and should be done - // after applying constraints. - const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); - const pad_left_dx = self.pad_left * metrics.face_width; - const pad_right_dx = self.pad_right * metrics.face_width; - const start_x = pad_left_dx; - const end_x = full_face_span - group.width - pad_right_dx; - return switch (self.align_horizontal) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the glyph doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. The - // left-side bound has priority if the group is too wide, though - // if we hit the .none prong we always have self.size != .none, - // so this should never happen. - .none => @max(start_x, @min(group.x, end_x)), - .start => start_x, - .end => @max(start_x, end_x), - .center => @max(start_x, (start_x + end_x) / 2), - // NOTE: .center1 implements the font_patcher rule of centering - // in the first cell even for multi-cell constraints. Since glyphs - // are not allowed to protrude to the left, this results in the - // left-alignment like .start when the glyph is wider than a cell. - .center1 => center1: { - const end1_x = metrics.face_width - group.width - pad_right_dx; - break :center1 @max(start_x, (start_x + end1_x) / 2); - }, - }; - } - }; -}; - test { @import("std").testing.refAllDecls(@This()); } @@ -512,197 +110,3 @@ test "Variation.Id: slnt should be 1936486004" { try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqualStrings("slnt", &(id.str())); } - -test "Constraints" { - const comparison = @import("../datastruct/comparison.zig"); - const getConstraint = @import("nerd_font_attributes.zig").getConstraint; - - // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. - - // Define grid metrics (matches font-family = JetBrains Mono) - const metrics: Metrics = .{ - .cell_width = 10, - .cell_height = 22, - .cell_baseline = 5, - .underline_position = 19, - .underline_thickness = 1, - .strikethrough_position = 12, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_thickness = 1, - .cursor_height = 22, - .icon_height = 21.12, - .icon_height_single = 44.48 / 3.0, - .face_width = 9.6, - .face_height = 21.12, - .face_y = 0.2, - }; - - // ASCII (no constraint). - { - const constraint: RenderOptions.Constraint = .none; - - // BBox of 'x' from JetBrains Mono. - const glyph_x: GlyphSize = .{ - .width = 6.784, - .height = 15.28, - .x = 1.408, - .y = 4.84, - }; - - // Any constraint width: do nothing. - inline for (.{ 1, 2 }) |constraint_width| { - try comparison.expectApproxEqual( - glyph_x, - constraint.constrain(glyph_x, metrics, constraint_width), - ); - } - } - - // Symbol (same constraint as hardcoded in Renderer.addGlyph). - { - const constraint: RenderOptions.Constraint = .{ .size = .fit }; - - // BBox of '■' (0x25A0 black square) from Iosevka. - // NOTE: This glyph is designed to span two cells. - const glyph_25A0: GlyphSize = .{ - .width = 10.272, - .height = 10.272, - .x = 2.864, - .y = 5.304, - }; - - // Constraint width 1: scale down and shift to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = metrics.face_width, - .height = metrics.face_width, - .x = 0, - .y = 5.64, - }, - constraint.constrain(glyph_25A0, metrics, 1), - ); - - // Constraint width 2: do nothing. - try comparison.expectApproxEqual( - glyph_25A0, - constraint.constrain(glyph_25A0, metrics, 2), - ); - } - - // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). - { - const constraint: RenderOptions.Constraint = .{ - .size = .cover, - .align_horizontal = .center, - .align_vertical = .center, - .pad_left = 0.025, - .pad_right = 0.025, - }; - - // BBox of '🥸' (0x1F978) from Apple Color Emoji. - const glyph_1F978: GlyphSize = .{ - .width = 20, - .height = 20, - .x = 0.46, - .y = 1, - }; - - // Constraint width 2: scale to cover two cells with padding, center; - try comparison.expectApproxEqual( - GlyphSize{ - .width = 18.72, - .height = 18.72, - .x = 0.44, - .y = 1.4, - }, - constraint.constrain(glyph_1F978, metrics, 2), - ); - } - - // Nerd Font default. - { - const constraint = getConstraint(0xea61).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.fit_cover1, constraint.size); - try std.testing.expectEqual(.icon, constraint.height); - try std.testing.expectEqual(.center1, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. - // NOTE: This icon is part of a group, so the - // constraint applies to a larger bounding box. - const glyph_EA61: GlyphSize = .{ - .width = 9.015625, - .height = 13.015625, - .x = 3.015625, - .y = 3.76525, - }; - - // Constraint width 1: scale and shift group to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = 7.2125, - .height = 10.4125, - .x = 0.8125, - .y = 5.950695224719102, - }, - constraint.constrain(glyph_EA61, metrics, 1), - ); - - // Constraint width 2: no scaling; left-align and vertically center group. - try comparison.expectApproxEqual( - GlyphSize{ - .width = glyph_EA61.width, - .height = glyph_EA61.height, - .x = 1.015625, - .y = 4.7483690308988775, - }, - constraint.constrain(glyph_EA61, metrics, 2), - ); - } - - // Nerd Font stretch. - { - const constraint = getConstraint(0xe0c0).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.stretch, constraint.size); - try std.testing.expectEqual(.cell, constraint.height); - try std.testing.expectEqual(.start, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. - const glyph_E0C0: GlyphSize = .{ - .width = 16.796875, - .height = 16.46875, - .x = -0.796875, - .y = 1.7109375, - }; - - // Constraint width 1: stretch and position to exactly cover one cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 1), - ); - - // Constraint width 1: stretch and position to exactly cover two cells. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(2 * metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 2), - ); - } -} diff --git a/vendor/libghostty-vt/src/font/face/coretext.zig b/vendor/libghostty-vt/src/font/face/coretext.zig index 1d133388..cd8bc7d8 100644 --- a/vendor/libghostty-vt/src/font/face/coretext.zig +++ b/vendor/libghostty-vt/src/font/face/coretext.zig @@ -291,7 +291,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { var glyphs = [_]macos.graphics.Glyph{@intCast(glyph_index)}; diff --git a/vendor/libghostty-vt/src/font/face/freetype.zig b/vendor/libghostty-vt/src/font/face/freetype.zig index 528f72d5..d04fe180 100644 --- a/vendor/libghostty-vt/src/font/face/freetype.zig +++ b/vendor/libghostty-vt/src/font/face/freetype.zig @@ -393,7 +393,7 @@ pub const Face = struct { } /// Get a rect that represents the position and size of the loaded glyph. - fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize { + fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.Glyph.Size { // If we're dealing with an outline glyph then we get the // outline's bounding box instead of using the built-in // metrics, since that's more precise and allows better @@ -427,7 +427,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !Glyph { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); diff --git a/vendor/libghostty-vt/src/font/face/web_canvas.zig b/vendor/libghostty-vt/src/font/face/web_canvas.zig index b4f9f5d5..a36a482d 100644 --- a/vendor/libghostty-vt/src/font/face/web_canvas.zig +++ b/vendor/libghostty-vt/src/font/face/web_canvas.zig @@ -189,7 +189,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { _ = opts; diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize.zig b/vendor/libghostty-vt/src/font/glyf_rasterize.zig new file mode 100644 index 00000000..f321214b --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize.zig @@ -0,0 +1,825 @@ +//! Rasterization for OpenType glyf outlines. +//! +//! This module intentionally lives in `font` rather than `font/opentype` +//! because I wanted to keep `font/opentype` dependency free on the font +//! package. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); + +const Glyph = @import("Glyph.zig"); +const glyf = @import("opentype/glyf.zig"); + +const DesignMetrics = Glyph.DesignMetrics; + +/// An owned, tightly packed alpha8 bitmap. +pub const Bitmap = struct { + width: u32, + height: u32, + data: []u8, + + // An empty 0x0 bitmap. + pub const empty: Bitmap = .{ .width = 0, .height = 0, .data = "" }; + + pub fn initEmpty(alloc: Allocator, width: u32, height: u32) Allocator.Error!Bitmap { + const data = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); + @memset(data, 0); + return .{ .width = width, .height = height, .data = data }; + } + + pub fn deinit(self: *Bitmap, alloc: Allocator) void { + alloc.free(self.data); + self.* = undefined; + } +}; + +pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; + +/// Rasterize a decoded glyf outline to a full-cell alpha bitmap. +/// +/// The returned bitmap is always `grid_metrics.cell_width * cell_width` by +/// `grid_metrics.cell_height`. `opts.constraint` is applied using the same +/// `RenderOptions.Constraint` machinery used by the platform font +/// backends. +/// +/// The caller owns the returned bitmap. +pub fn rasterize( + alloc: Allocator, + outline: glyf.Glyf.Outline, + design: DesignMetrics, + opts: Glyph.RenderOptions, +) Error!Bitmap { + assert(design.units_per_em > 0); + assert(design.advance_width > 0); + assert(design.line_height > 0); + + // Calculate our final width/height. + const width: u32 = std.math.mul( + u32, + opts.grid_metrics.cell_width, + opts.cell_width orelse 1, + ) catch std.math.maxInt(u32); + const height = opts.grid_metrics.cell_height; + assert(width > 0 and height > 0); + + // If we have no contours or points then we have no drawable shape, but the + // caller still asked for a cell-sized bitmap. Return that full bitmap with + // zero coverage so downstream atlas/upload code doesn't need a separate + // size contract for empty glyphs. + if (outline.contours.len == 0 or outline.points.len == 0) return Bitmap.initEmpty(alloc, width, height); + + // Glyf entries have a header bounding box, but this rasterizer operates on + // the decoded Outline only. Recompute bounds from the decoded coordinate + // data so placement and scaling follow the geometry we actually draw. If a + // source glyf header disagrees with its points, the point data is the safer + // source of truth for rasterization; the header belongs in decode-time + // validation/metadata, not this font-level drawing API. + const bounds: Bounds = bounds: { + var bounds: Bounds = .{ + .x_min = @floatFromInt(outline.points[0].x), + .y_min = @floatFromInt(outline.points[0].y), + .x_max = @floatFromInt(outline.points[0].x), + .y_max = @floatFromInt(outline.points[0].y), + }; + for (outline.points[1..]) |p| { + const x: f64 = @floatFromInt(p.x); + const y: f64 = @floatFromInt(p.y); + bounds.x_min = @min(bounds.x_min, x); + bounds.y_min = @min(bounds.y_min, y); + bounds.x_max = @max(bounds.x_max, x); + bounds.y_max = @max(bounds.y_max, y); + } + break :bounds bounds; + }; + + // Degenerate point bounds can't produce filled area and would make the + // point-to-bitmap transform divide by zero, so return a full transparent + // bitmap just like an empty outline. + if (bounds.width() == 0 or bounds.height() == 0) return Bitmap.initEmpty(alloc, width, height); + + // Build the surface we'll draw on. This is a simple alpha8 drawing. + var sfc: z2d.Surface = try .init( + .image_surface_alpha8, + alloc, + @intCast(width), + @intCast(height), + ); + defer sfc.deinit(alloc); + + var path: z2d.Path = .empty; + defer path.deinit(alloc); + + const placement: Placement = .init(bounds, design, opts); + for (0..outline.contours.len) |i| try appendContourPath( + alloc, + &path, + outline.contour(i), + bounds, + placement, + ); + + try z2d.painter.fill( + alloc, + &sfc, + &.{ .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = 255 } }, + } }, + path.nodes.items, + .{}, + ); + + return .{ + .width = width, + .height = height, + .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), + }; +} + +const Bounds = struct { + x_min: f64, + y_min: f64, + x_max: f64, + y_max: f64, + + fn width(self: Bounds) f64 { + return self.x_max - self.x_min; + } + + fn height(self: Bounds) f64 { + return self.y_max - self.y_min; + } +}; + +/// Cell-relative pixel rectangle where the decoded outline bounds should be +/// rasterized within the output bitmap. +/// +/// This is deliberately the placement of the outline's computed point bounds, +/// not the full declared advance/line-height box. `advance_width` and +/// `line_height` describe the design-space layout box the outline was drawn +/// within: they include intentional bearings and whitespace around the visible +/// points. We use that declared box when applying `RenderOptions.Constraint` so +/// sizing and alignment preserve those bearings consistently with other font +/// backends; once that is resolved, we rasterize only the actual outline bounds +/// into this rectangle. +/// +/// ```text +/// output bitmap / terminal cell +/// ╭────────────────────────────────────────────────────────────────────────╮ top +/// │ │ +/// │ declared advance/line-height box │ +/// │ (outer layout box used for constraints) │ +/// │ ╭────────────────────────────────────────────────────────────────╮ │ +/// │ │ │ │ +/// │◀──────── x ────────▶╭────────── width ──────────╮ │ │ +/// │ │ │ Placement │ ▲ height │ │ +/// │ │ │ outline point bounds │ │ │ │ +/// │ │ │ pixels to draw │ │ │ │ +/// │ │ │ │ │ │ │ +/// │ │ ╰───────────────────────────╯ ▼ │ │ +/// │ ╰─────────────────▲──────────────────────────────────────────────╯ │ +/// │ │ y │ +/// ╰────────────────────────────────────────────────────────────────────────╯ bottom +/// x is measured from the bitmap left to the Placement left. +/// y is measured from the bitmap bottom to the Placement bottom. +/// bitmap_height is the full top-to-bottom bitmap height. +/// ``` +/// +/// Constraints are applied to the outer box so the whitespace remains part of +/// alignment decisions. `Placement` is the inner rectangle after that outer box +/// has been constrained. +const Placement = struct { + /// Left edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's left edge. + x: f64, + + /// Bottom edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's bottom edge. This matches the cell-relative y axis + /// used by font.Glyph.Size and is converted to z2d's y-down axis when + /// points are transformed. + y: f64, + + /// Width of the rasterized outline bounds in bitmap pixels after applying + /// font.Glyph.RenderOptions.Constraint. + width: f64, + + /// Height of the rasterized outline bounds in bitmap pixels after applying + /// font.Glyph.RenderOptions.Constraint. + height: f64, + + /// Full bitmap height in pixels, used to convert cell-relative y-up-ish + /// placement into the y-down coordinate system used by z2d surfaces. + bitmap_height: f64, + + /// Calculate where the decoded point bounds should land in the output + /// bitmap. + /// + /// The glyf protocol supplies declared metrics (`units_per_em`, + /// `advance_width`, and `line_height`) in design units, while Ghostty's + /// font constraint code works in cell-relative pixels. We first map the em + /// square to one cell height, matching the linked glyph rasterizer's + /// baseline model where design-space `y=0` is the bottom/baseline of the em + /// and `y=units_per_em` is its top. Then we describe the actual outline + /// bounds as a relative sub-rectangle of the declared advance/line-height + /// box. That declared box includes any intentional side bearings or + /// vertical whitespace around the outline; constraints should apply to that + /// layout box rather than to the tight point bounds alone. This returns the + /// final pixel rectangle for only the outline bounds that we will rasterize. + fn init( + bounds: Bounds, + design: DesignMetrics, + opts: Glyph.RenderOptions, + ) Placement { + // Start with protocol-like design units mapped so that the em square + // occupies one cell. This makes units_per_em the scale reference and + // preserves the linked rasterizer's y=0 baseline/bottom behavior. + // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ + // align the declared advance/line-height box using existing font logic. + const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / + @as(f64, @floatFromInt(design.units_per_em)); + + // Convert the decoded point bounds into the same pixel coordinate space + // expected by RenderOptions.Constraint. This rectangle is the visible + // outline bounds, not the full advance/line-height layout box. + const glyph: Glyph.Size = .{ + .width = bounds.width() * scale, + .height = bounds.height() * scale, + .x = bounds.x_min * scale, + .y = bounds.y_min * scale, + }; + + // Convert the declared layout box to pixels. This is the box that + // carries intentional bearings/whitespace and should be constrained. + const group_width = @as(f64, @floatFromInt(design.advance_width)) * scale; + const group_height = @as(f64, @floatFromInt(design.line_height)) * scale; + + // Apply the same fit/cover/stretch/alignment/padding rules used by + // normal font rendering. The result is still the outline bounds, but + // placed as if its containing advance/line-height box was constrained. + const constraint: Glyph.RenderOptions.Constraint = constraint: { + var constraint = opts.constraint; + if (group_width > 0 and group_height > 0) { + // Tell Constraint that `glyph` is a sub-rectangle of the + // declared layout box. Constraint will size/align the outer box + // and then return the corresponding transformed inner box. + constraint.relative_width = glyph.width / group_width; + constraint.relative_height = glyph.height / group_height; + constraint.relative_x = glyph.x / group_width; + constraint.relative_y = glyph.y / group_height; + } + break :constraint constraint; + }; + const constrained = constraint.constrain( + glyph, + opts.grid_metrics, + opts.constraint_width, + ); + + // Store the final outline placement plus the full bitmap height needed + // later to flip from cell-relative y to z2d's y-down surface space. + return .{ + .x = constrained.x, + .y = constrained.y, + .width = constrained.width, + .height = constrained.height, + .bitmap_height = @floatFromInt(opts.grid_metrics.cell_height), + }; + } +}; + +const Point = struct { + x: f64, + y: f64, +}; + +/// Append one contour to a z2d path. +/// +/// Glyf contours are quadratic outlines with explicit on-curve points and +/// off-curve control points. Consecutive off-curve points imply an on-curve +/// point halfway between them, and a contour may begin with an off-curve point. +/// This normalizes those cases while walking the closed contour and emits z2d +/// line/cubic-curve operations in bitmap coordinates. +fn appendContourPath( + alloc: Allocator, + path: *z2d.Path, + contour: []const glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Error!void { + if (contour.len == 0) return; + + const first = contour[0]; + const last = contour[contour.len - 1]; + + var current: Point = undefined; + var i: usize = 0; + + // Choose the starting on-curve point for this closed contour. If the first + // point is off-curve then the contour logically starts either at the final + // on-curve point, or at the implied midpoint between the final and first + // off-curve points. + if (first.on_curve) { + i = 1; + current = transformPoint( + first, + bounds, + placement, + ); + } else if (last.on_curve) { + current = transformPoint( + last, + bounds, + placement, + ); + } else { + current = midpoint( + transformPoint(last, bounds, placement), + transformPoint(first, bounds, placement), + ); + } + + // Move to the beginning + try path.moveTo(alloc, current.x, current.y); + + // Go through the points and connect em! + while (i < contour.len) { + const p = contour[i]; + + // On-curve points connect to the current point with a straight line. + if (p.on_curve) { + current = transformPoint(p, bounds, placement); + try path.lineTo(alloc, current.x, current.y); + i += 1; + continue; + } + + // Off-curve points are quadratic control points. The following point is + // either the curve endpoint or, if it is also off-curve, contributes an + // implied on-curve endpoint halfway between the two controls. + const control = transformPoint(p, bounds, placement); + const next = contour[(i + 1) % contour.len]; + const end = if (next.on_curve) transformPoint( + next, + bounds, + placement, + ) else midpoint( + control, + transformPoint(next, bounds, placement), + ); + + // z2d paths only expose cubic curves, so convert the TrueType + // quadratic segment to an equivalent cubic segment before appending it. + const c1 = Point{ + .x = current.x + ((2.0 / 3.0) * (control.x - current.x)), + .y = current.y + ((2.0 / 3.0) * (control.y - current.y)), + }; + const c2 = Point{ + .x = end.x + ((2.0 / 3.0) * (control.x - end.x)), + .y = end.y + ((2.0 / 3.0) * (control.y - end.y)), + }; + try path.curveTo( + alloc, + c1.x, + c1.y, + c2.x, + c2.y, + end.x, + end.y, + ); + + current = end; + + // If we consumed an explicit on-curve endpoint then skip it; otherwise + // the next off-curve point still needs to be used as the control point + // for the following quadratic segment. + i += if (next.on_curve) 2 else 1; + } + + try path.close(alloc); +} + +/// Convert a decoded glyf point from design-space coordinates to z2d bitmap +/// coordinates. +/// +/// `bounds` describes the decoded outline's point/control bounds in glyf +/// design units. `placement` describes where those bounds should land in the +/// output bitmap after constraints are applied. Glyf coordinates are y-up; z2d +/// surfaces are y-down, so this also flips the y axis using +/// `placement.bitmap_height`. +fn transformPoint( + p: glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Point { + const scale_x = placement.width / bounds.width(); + const scale_y = placement.height / bounds.height(); + const x_design: f64 = @floatFromInt(p.x); + const y_design: f64 = @floatFromInt(p.y); + return .{ + .x = placement.x + ((x_design - bounds.x_min) * scale_x), + .y = placement.bitmap_height - placement.y - + ((y_design - bounds.y_min) * scale_y), + }; +} + +/// Return the implied on-curve point between two off-curve TrueType control +/// points. +fn midpoint(a: Point, b: Point) Point { + return .{ + .x = (a.x + b.x) / 2.0, + .y = (a.y + b.y) / 2.0, + }; +} + +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +test { + _ = @import("glyf_rasterize_png_test.zig"); +} + +test "glyf_rasterize: empty outline returns empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + var bm = try rasterize(alloc, .{ .points = &.{}, .contours = &.{} }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: square fills bitmap center" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); +} + +test "glyf_rasterize: quadratic contour renders" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: consecutive off-curve points render" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: units per em controls baseline scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 2000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + // With a 2000-unit em in a 20px cell, this 1000-unit square occupies the + // bottom half of the cell. This matches the linked rasterizer's y=0 + // baseline/bottom behavior and proves units_per_em is the scale reference. + try testing.expect(bm.data[15 * bm.width + 5] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[5 * bm.width + 5]); +} + +test "glyf_rasterize: degenerate outline returns full empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: contour can start off curve with final on curve point" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: contour can start with implied midpoint" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: multiple contours render independently" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 400, .on_curve = true }, + .{ .x = 0, .y = 400, .on_curve = true }, + .{ .x = 600, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 600, .y = 1000, .on_curve = true }, + }, + .contours = &.{ 3, 7 }, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[16 * bm.width + 4] > 200); + try testing.expect(bm.data[4 * bm.width + 16] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 10]); +} + +test "glyf_rasterize: non-zero bearings preserve declared whitespace" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 17]); +} + +test "glyf_rasterize: negative y coordinates descend below baseline" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = 750, .on_curve = true }, + .{ .x = 0, .y = 750, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[2 * bm.width + 10]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[18 * bm.width + 10] > 200); +} + +test "glyf_rasterize: two-cell bitmap and constraint render within width" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + }, + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 40), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 20] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 37]); +} + +test "glyf_rasterize: line height does not change unconstrained em scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[2 * bm.width + 10] > 200); + try testing.expect(bm.data[17 * bm.width + 10] > 200); +} diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig new file mode 100644 index 00000000..e8ad70fd --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig @@ -0,0 +1,301 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const z2d = @import("z2d"); + +const glyf_rasterize = @import("glyf_rasterize.zig"); +const glyf = @import("opentype/glyf.zig"); + +const log = std.log.scoped(.glyf_rasterize); + +const test_glyf_payloads = [_][]const u8{ + // Nerd Font branch, folder, home, heart, and Rust cog outlines from: + // https://github.com/raphamorim/glyph-protocol-examples/blob/main/bubbletea/main.go + "AAIARv8zAhIDnQAZAB0AABcjNTQ3Njc3Njc2NTUjNxcjFRQGBwcGBwYVEQcRM82HJxs3SyoUE2aPjmY0NCUvDxSHh83rVzgoIzAbKSZAoaenvF5kIhkfHSM9AVdXAn8=", + "AAEAAP/UA5wC/AAVAAAXIiY1ETQ2MzMyFxcWMyEyFgcRFgYjcy9ERC/nOCQjER0BITBEAQFEMCxELwJBMEQvLhdEL/4yL0Q=", + "AAEAAP+aBBEDNgA+AAABFAYjIxMUBxUUBisFIiY9AjQmIyMiBh0CFAYrAiIiJwYiIyMiJjc1MjQ1NSMiJjQ3ATYzMhcBFgQOJBY5AQEqHh0GBzsrHioiGHMYIioeLDkBBAMBBAIcHiwBAToYIhIBzg4aFw8BzBcBahgi/t4KBB4eKioeLHQYIiIYdCweKgICKh7KBAJ+IDISAZQODP5qFA==", + "AAEAAP/dA5sC+QAZAAATJjU1NDY3NhYXFzc2NhcWFhUVFAcBBiMiJ1ZWel09eCwWFSx4PV16Vv66FB0eFAEhUHULXpAQCiYsFhYsJgoQkF4LdVD+zxMT", + "AAoAAP/YAyEC+AENARcBWgFlAW8BfQGIAbsBxAHNAAAAMhYXFhYyNjc2MzIXFhcWFxY3NjMyFhcWFxY3NjMyFxcHBxcWNzYXFgcGFxYzMhcWBw4CFAcUFQcUFxYWFRQGFhcWFgcGFBcWFxYGBwYUFxYGBw4CFhYXFgcGBwYVFBcWBwYjIgYXFgYnJgcGFxcHBiInJgYHBiMiJyYHBgcGBiMiJyYiBwYiJyYmBwYGJyYmJyYHBiImJyYmBwYiJjc3JyYHBicmNzYmIwYmNzY2Nzc0JyYmNTQ3NiYnJiY3NjQnJiY0Njc2NjQnJicmJjY3NjYnJjc3Mjc2NScmJyYnJjYXMjY1NCYmJyc3NhcWNzcnJjYzMhcWNjc2NjMyFxY3Njc2NjMyFxYyNzY3FyIHBhYzMjYnJgczBwYHBhUUMzIXFhYHBgcOAhUGFQciFhcWFxYXFhcWFjc2NzY3NjMzNzYvAiYnJjU0NzcnJicmJicnBwYGJyYnBwYGFxYyNzYmJyYFIgcGFBcWNicmBRcWBwYPAgYXFzM1NRcVMzY3NjU0JyYjBxUzMhcWFQcjIhUGFjMyNzYyFxYXFhUWFhcWNzY/AjY2Fxc2NjQjIiYnJicmJyYnJiMGIgcGFjMyNiclIgcGFjc2JyYBjQQICgcECAgIEQMJBgUCAwUGEBMDBgQEAwUDFBIFAwQEAQEEBRIZBQUGBQMCFxUFBwwBAgICARgSChoEFBcEExEUDwQECBATERMEGAoGCAQEBhEHAxUZCAsGAxcYBAUGChoVAgMBAQQEBhQSCgMECgQRFAUEBgcGBAUQEAgLDQwOCwoODQgFBg4CBRUPDAQEBAgTFAYIAQEEBREaBAYGBQQYGQYKAQQCARgSCg0NBBQXBBMQERIEBg4KCAIDCg0ECBEUBA0QBwQFEBcBAQICAgsIGRgCAgIBBAMFGhIFBAEBCAMFEhMIBAQEBgUREQQGCAcEBgQQDwoLCQQFCQcMDBARCg0HPgEPQjEfoJ8NJzACAykCBAQCAQEEAgIDGAgEAwggCg0BAQMCDBABAgIBHRsDBw4PAx8xFkQTCRQSEAkHEvoMDgcHGAgEBAcFAjAHBA0OFBQTBf3wBAsIAh0cAQMKBFOENjcIERsLLzEkJAECAXl5ARgBBBQZEAYEBQYBRCEnKiYSBgYGECAcARg/NhQLDgkLBQkQBimQEAQPChESCA4BVRAGCigLChEFAvgEEQsIBgkREA8FCAIBDA0KERYCAgkIBAQWFwIDBQYFBBkVAwIDBhkDBAQEAQEBAQcEAwQHBSQICAgMEREICwoFBggKDAgQEQwJBAQCCAgHGAYDAwQIAxAXBwMGFBkKBgQCAxYWBAQJCAQVHAwOAwQQEwYREBMVFBMCEA0GAgQmAgQPDAoSFgQJCQgXFgICBAYEBhgVBgEMFgQKAwMGBAQEBgQTEQgJCQwQEAgLCwQMBAkHBAoDAwkLDAgGCAgSFwcEAwQHAgIGBQQXDAEEBQEGCgQTBAcGBAICFxYICAkEFhEKDA0BARcSBBEPEw8ERQcKHiAKBScEESgaBAIDCjghJh4BBAIBAQEBBAECAhUjEQMJAgcIGBMBAQURFgcNDAMHCgYgIQY0IxAcBAETEwgDBBObARgMCwwIFAQEAgIIHAYKKAsDAwkYDQQNDRAjLg9eXgE4AQQHDhQHA4g0AgQqKwICGgUECAkVHAEHFAMDCAcKAx4iCgcGARwCBAwPJjAHDgQCwgMJIiIJAg0UFBIRDgQ=", +}; + +/// Return deterministic font metrics for the PNG reference test. +/// +/// These are intentionally minimal: the rasterizer only needs cell geometry, +/// face geometry, and icon heights for constraint calculations. +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +/// Decode a base64-encoded glyf protocol payload into an owned outline. +/// +/// The payload is a complete simple-glyph `glyf` table entry. The returned +/// outline owns decoded point and contour storage and must be deinitialized by +/// the caller. +fn decodeGlyfPayload(alloc: Allocator, payload: []const u8) !glyf.Glyf.Outline { + const decoder = std.base64.standard.Decoder; + const size = try decoder.calcSizeForSlice(payload); + const data = try alloc.alloc(u8, size); + defer alloc.free(data); + + try decoder.decode(data, payload); + const entry = try glyf.Glyf.Entry.init(data); + return try entry.decode(alloc); +} + +/// Copy a tightly packed alpha bitmap into the alpha atlas at `dst_x`, `dst_y`. +/// +/// The destination rectangle must fit inside the atlas. This is a test helper, +/// so it trusts the hardcoded atlas layout rather than clipping. +fn blitBitmap(atlas: *z2d.Surface, bm: glyf_rasterize.Bitmap, dst_x: usize, dst_y: usize) void { + const dst_width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + for (0..bm.height) |y| { + const src_start = y * bm.width; + const src_end = src_start + bm.width; + const dst_start = (dst_y + y) * dst_width + dst_x; + @memcpy(dst[dst_start .. dst_start + bm.width], bm.data[src_start..src_end]); + } +} + +/// Draw faint terminal-cell outlines into one row of the alpha atlas. +/// +/// The boxes make cell advance and placement behavior visible in the reference +/// PNG without overpowering the rendered glyph coverage. +fn drawCellBoxes(atlas: *z2d.Surface, y: usize, cell_width: usize, cell_height: usize) void { + const width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const alpha = 64; + + var x: usize = 0; + while (x < width) : (x += cell_width) { + const right = @min(x + cell_width - 1, width - 1); + const bottom = y + cell_height - 1; + + for (x..right + 1) |px| { + dst[y * width + px] = @max(dst[y * width + px], alpha); + dst[bottom * width + px] = @max(dst[bottom * width + px], alpha); + } + for (y..bottom + 1) |py| { + dst[py * width + x] = @max(dst[py * width + x], alpha); + dst[py * width + right] = @max(dst[py * width + right], alpha); + } + } +} + +/// Compare a generated atlas PNG against the checked-in reference image. +/// +/// On missing reference or mismatch, copy the generated PNG into the workspace +/// as `glyf_rasterize_test.png`. On pixel mismatch, also write +/// `glyf_rasterize_diff.png`, where red is reference-only coverage and green is +/// newly generated coverage. Returns true when a difference was found. +fn diffAtlas( + alloc: Allocator, + atlas: *z2d.Surface, + generated_path: []const u8, +) !bool { + const ref_path = "src/font/testdata/glyf_rasterize.png"; + + const generated_file = try std.fs.openFileAbsolute(generated_path, .{ .mode = .read_only }); + defer generated_file.close(); + const generated_bytes = try generated_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(generated_bytes); + + const cwd_absolute = try std.fs.cwd().realpathAlloc(alloc, "."); + defer alloc.free(cwd_absolute); + + const ref_file = std.fs.cwd().openFile(ref_path, .{ .mode = .read_only }) catch |err| { + log.err("Can't open reference file {s}: {}", .{ ref_path, err }); + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + return true; + }; + defer ref_file.close(); + const ref_bytes = try ref_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(ref_bytes); + + if (std.mem.eql(u8, generated_bytes, ref_bytes)) return false; + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + + const ref_rgba = try wuffs.png.decode(alloc, ref_bytes); + defer alloc.free(ref_rgba.data); + + if (ref_rgba.width != atlas.getWidth() or ref_rgba.height != atlas.getHeight()) { + log.err( + "glyf rasterize visual output dimensions differ from reference: " ++ + "test={s} ({d}x{d}), reference={s} ({d}x{d})", + .{ test_path, atlas.getWidth(), atlas.getHeight(), ref_path, ref_rgba.width, ref_rgba.height }, + ); + return true; + } + + var diff = try z2d.Surface.init( + .image_surface_rgb, + alloc, + atlas.getWidth(), + atlas.getHeight(), + ); + defer diff.deinit(alloc); + + const test_gray = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const diff_pix = diff.image_surface_rgb.buf; + var differs = false; + for (test_gray, 0..) |t, i| { + const r = ref_rgba.data[i * 4]; + if (t == r) { + diff_pix[i].r = t / 3; + diff_pix[i].g = t / 3; + diff_pix[i].b = t / 3; + } else { + differs = true; + diff_pix[i].r = r; + diff_pix[i].g = t; + } + } + + if (!differs) { + log.err( + "generated glyf rasterize PNG bytes differ from reference but pixels match; " ++ + "test={s}, reference={s}", + .{ test_path, ref_path }, + ); + return true; + } + + const diff_path = "./glyf_rasterize_diff.png"; + try z2d.png_exporter.writeToPNGFile(diff, diff_path, .{}); + log.err( + "glyf rasterize visual output differs from reference: test={s}, reference={s}, diff={s}", + .{ test_path, ref_path, diff_path }, + ); + + return true; +} + +test "glyf_rasterize: bubbletea glyph protocol examples match reference image" { + const testing = std.testing; + const alloc = testing.allocator; + + // The generated PNG is a visual atlas for reading placement behavior. + // Each column below is one terminal cell. The five payloads are rendered in + // order: branch, folder, home, heart, rust. + // + // ```text + // columns: 0 1 2 3 4 5 6 7 8 9 + // ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ + // row 0 │B│F│H│♥│R│ │ │ │ │ │ narrow/default: one-cell bitmap stride + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 1 │B │F │H │♥ │R │ width=2: same glyphs, two-cell bitmaps + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 2 │ B│ F│ H│ ♥│ R│ width=2 + horizontal center alignment + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 3 │B │F │H │♥ │R │ advance_width=2000: wider design box + // └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ + // ``` + // + // The faint grid lines in the PNG are these cell boundaries. Rows 2 and 3 + // are the design-metric/placement regression checks: row 2 centers a + // one-cell-wide design box inside two cells, while row 3 centers a two-cell + // design box so the visible glyph returns to the start of each span. + const cell_width = 20; + const cell_height = 20; + const columns = test_glyf_payloads.len; + const narrow_stride_x = cell_width; + const wide_stride_x = cell_width * 2; + const row_count = 4; + + var atlas = try z2d.Surface.init( + .image_surface_alpha8, + alloc, + @intCast(wide_stride_x * columns), + cell_height * row_count, + ); + defer atlas.deinit(alloc); + + for (test_glyf_payloads, 0..) |payload, i| { + var outline = try decodeGlyfPayload(alloc, payload); + defer outline.deinit(alloc); + + var narrow = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + }); + defer narrow.deinit(alloc); + blitBitmap(&atlas, narrow, i * narrow_stride_x, 0); + + var wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + }); + defer wide.deinit(alloc); + blitBitmap(&atlas, wide, i * wide_stride_x, cell_height); + + var centered = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer centered.deinit(alloc); + blitBitmap(&atlas, centered, i * wide_stride_x, cell_height * 2); + + var designed_wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 2000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer designed_wide.deinit(alloc); + blitBitmap(&atlas, designed_wide, i * wide_stride_x, cell_height * 3); + } + + for (0..row_count) |row| drawCellBoxes(&atlas, row * cell_height, cell_width, cell_height); + + var dir = testing.tmpDir(.{}); + defer dir.cleanup(); + const tmp_dir = try dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_dir); + + const generated_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize.png", .{tmp_dir}); + defer alloc.free(generated_path); + try z2d.png_exporter.writeToPNGFile(atlas, generated_path, .{}); + + try testing.expect(!try diffAtlas(alloc, &atlas, generated_path)); +} diff --git a/vendor/libghostty-vt/src/font/main.zig b/vendor/libghostty-vt/src/font/main.zig index a8522afe..0c218bbd 100644 --- a/vendor/libghostty-vt/src/font/main.zig +++ b/vendor/libghostty-vt/src/font/main.zig @@ -15,6 +15,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); +pub const glyf_rasterize = @import("glyf_rasterize.zig"); pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); diff --git a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig b/vendor/libghostty-vt/src/font/nerd_font_attributes.zig index f4a19d96..fc988496 100644 --- a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig +++ b/vendor/libghostty-vt/src/font/nerd_font_attributes.zig @@ -4,7 +4,7 @@ //! This file provides info extracted from the nerd fonts patcher script, //! specifying the scaling/positioning attributes of various glyphs. -const Constraint = @import("face.zig").RenderOptions.Constraint; +const Constraint = @import("Glyph.zig").RenderOptions.Constraint; /// Get the constraints for the provided codepoint. pub fn getConstraint(cp: u21) ?Constraint { diff --git a/vendor/libghostty-vt/src/font/opentype/glyf.zig b/vendor/libghostty-vt/src/font/opentype/glyf.zig index 7392729a..912b5fe8 100644 --- a/vendor/libghostty-vt/src/font/opentype/glyf.zig +++ b/vendor/libghostty-vt/src/font/opentype/glyf.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const sfnt = @import("sfnt.zig"); /// Glyph Data Table @@ -15,6 +16,49 @@ const sfnt = @import("sfnt.zig"); pub const Glyf = struct { data: []const u8, + /// A decoded glyph outline. + /// + /// The `contours` slice is the list of end point indices and + /// `points` owns all the points. Glyf guarantees that contour + /// points are sequential so we can just store the end and calculate + /// the points that way. Use the helpers to make it ergonomic. + pub const Outline = struct { + /// List of contour end points. Calculate the full list of + /// points using points[prev...this+1] + contours: []const sfnt.uint16, + + /// The backing storage of all points in the entry. + points: []const Point, + + /// A single decoded point in a simple glyph contour. + pub const Point = struct { + x: i32, + y: i32, + on_curve: bool, + }; + + /// Return the point slice for the contour at `index`. + /// + /// The returned slice references `points` and is invalidated when + /// this outline is deinitialized. + pub fn contour(self: Outline, index: usize) []const Point { + const start = if (index == 0) + 0 + else + @as(usize, self.contours[index - 1]) + 1; + const end = @as(usize, self.contours[index]) + 1; + return self.points[start..end]; + } + + /// Free all memory owned by this outline. Pass in the same + /// allocator used for decoding. + pub fn deinit(self: *Outline, alloc: Allocator) void { + alloc.free(self.contours); + alloc.free(self.points); + self.* = undefined; + } + }; + /// https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#table-organization pub const Entry = struct { header: Header, @@ -241,6 +285,12 @@ pub const Glyf = struct { TooManyPoints, }; + /// Errors that can be returned from `Entry.decode()`. + pub const DecodeError = SizeError || Allocator.Error || error{ + /// Coordinate delta accumulation overflowed. + CoordinateOverflow, + }; + /// Determines the size (in bytes) of this entry. /// /// If the entry is valid, returns the number of bytes @@ -393,6 +443,149 @@ pub const Glyf = struct { // No issues found, the glyf entry is valid, return its length. return @sizeOf(Header) + fbs.pos; } + + /// Decode this simple glyph entry into an owned outline. + /// + /// NOTE: Currently produces errors when given composite glyphs + /// or any glyphs that have hinting instructions included. + pub fn decode(self: Entry, alloc: Allocator) DecodeError!Glyf.Outline { + // We only support simple glyphs. + switch (self.entryType()) { + .simple => {}, + .composite => return error.CompositeNotSupported, + } + + var fbs = std.io.fixedBufferStream(self.data); + const reader = fbs.reader(); + + // A zero-contour glyph may be header-only. See size for the + // reason for the hardcoded 2 here. + const num_contours: usize = @intCast(self.header.numberOfContours); + if (num_contours == 0 and self.data.len < 2) return .{ + .points = &.{}, + .contours = &.{}, + }; + + // We now know our full amount of contour ending points. + const end_points = try alloc.alloc(sfnt.uint16, num_contours); + errdefer alloc.free(end_points); + + // If we have no contours, then the only possible remaining + // field is instructionLength. Instructions are not supported. + if (num_contours == 0) { + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + return .{ .points = &.{}, .contours = end_points }; + } + + // The number of points is determined by the final end point + // entry since the entries have to be monotonic (something + // we verify below). + const point_count: usize = point_count: { + var prev_end_point: isize = -1; + + // Go through the end points array and update our end_points + // with the valid index. The final endpoint tells us our point + // count, since endpoints are stored as inclusive point indices. + for (0..end_points.len) |i| { + const index = try reader.readInt(sfnt.uint16, .big); + if (index <= prev_end_point) return error.EndPointsOutOfOrder; + prev_end_point = index; + end_points[i] = index; + } + + // The final point tells us our point count. + break :point_count @as(usize, end_points[end_points.len - 1]) + 1; + }; + + // Instructions are not supported. + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + + // Allocate our points right away even though the next entries + // are flags. We want to do this so that if the allocator is + // a bump allocator, the flags free will actually free it. + const points = try alloc.alloc(Glyf.Outline.Point, point_count); + errdefer alloc.free(points); + + // This is EXTREMELY annoying but all the flags are separate + // from the points so we have to do some allocation here since + // its a dynamic amount and we need to save the values for later. + // + // Typical glyphs have small point counts, so use stack storage + // first while still falling back to the caller's allocator for + // unusually large outlines. + var flags_stack = std.heap.stackFallback(4096, alloc); + const flags_alloc = flags_stack.get(); + const flags = try flags_alloc.alloc(SimpleFlags, point_count); + defer flags_alloc.free(flags); + { + var point_i: usize = 0; + while (point_i < point_count) { + const flag: SimpleFlags = @bitCast(try reader.readByte()); + flags[point_i] = flag; + point_i += 1; + + if (flag.repeat) { + const repeat_count: usize = try reader.readByte(); + if (point_i + repeat_count > point_count) return error.TooManyPoints; + + for (0..repeat_count) |_| { + flags[point_i] = flag; + point_i += 1; + } + } + } + } + + // Go through x coordinate deltas + var x: i32 = 0; + for (flags, points) |flag, *point| { + const dx: i32 = if (flag.x_short) short: { + break :short if (flag.x_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.x_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + x = std.math.add( + i32, + x, + dx, + ) catch return error.CoordinateOverflow; + point.x = x; + } + + // Go through y coordinate deltas + var y: i32 = 0; + for (flags, points) |flag, *point| { + const dy: i32 = if (flag.y_short) short: { + break :short if (flag.y_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.y_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + y = std.math.add( + i32, + y, + dy, + ) catch return error.CoordinateOverflow; + point.y = y; + point.on_curve = flag.on_curve; + } + + return .{ + .points = points, + .contours = end_points, + }; + } }; /// Initialize the table from the provided data. @@ -451,7 +644,37 @@ pub fn getGlyph(font: sfnt.SFNT, index: usize) !struct { usize, Glyf.Entry } { return .{ end_offset - start_offset, try glyf.entry(start_offset) }; } +fn testAppendInt( + buf: *std.ArrayList(u8), + alloc: Allocator, + comptime T: type, + value: T, +) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .big); + try buf.appendSlice(alloc, &bytes); +} + +fn testAppendHeader( + buf: *std.ArrayList(u8), + alloc: Allocator, + number_of_contours: i16, + x_min: i16, + y_min: i16, + x_max: i16, + y_max: i16, +) !void { + try testAppendInt(buf, alloc, i16, number_of_contours); + try testAppendInt(buf, alloc, i16, x_min); + try testAppendInt(buf, alloc, i16, y_min); + try testAppendInt(buf, alloc, i16, x_max); + try testAppendInt(buf, alloc, i16, y_max); +} + test "glyf" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -475,13 +698,188 @@ test "glyf" { try testing.expect(glyph_A.entryType() == .simple); try testing.expect(len_A >= try glyph_A.size()); + var outline_A = try glyph_A.decode(alloc); + defer outline_A.deinit(alloc); + try testing.expectEqual(@as(usize, @intCast(glyph_A.header.numberOfContours)), outline_A.contours.len); + try testing.expect(outline_A.points.len > 0); + // Glyph "Ĩ" is at index 265. const len_Itilde, const glyph_Itilde = try getGlyph(font, 265); try testing.expect(glyph_Itilde.entryType() == .simple); try testing.expect(len_Itilde >= try glyph_Itilde.size()); } +test "glyf: decode triangle" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 100, 100, 900, 900); + try testAppendInt(&buf, alloc, u16, 2); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try testAppendInt(&buf, alloc, i16, 500); + try testAppendInt(&buf, alloc, i16, -400); + try testAppendInt(&buf, alloc, i16, 800); + try testAppendInt(&buf, alloc, i16, 900); + try testAppendInt(&buf, alloc, i16, -800); + try testAppendInt(&buf, alloc, i16, 0); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(i16, 100), glyph.header.xMin); + try testing.expectEqual(@as(i16, 900), glyph.header.xMax); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(usize, 3), outline.points.len); + const contour = outline.contour(0); + try testing.expectEqual(@as(usize, 3), contour.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 500, .y = 900, .on_curve = true }, contour[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 100, .y = 100, .on_curve = true }, contour[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 900, .y = 100, .on_curve = true }, contour[2]); +} + +test "glyf: decode multiple contours" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 2, 0, 0, 30, 10); + try testAppendInt(&buf, alloc, u16, 1); // first contour ends at point 1 + try testAppendInt(&buf, alloc, u16, 3); // second contour ends at point 3 + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + for (0..4) |_| try buf.append(alloc, 0x01); // on curve + for ([_]i16{ 0, 10, 10, 10 }) |dx| try testAppendInt(&buf, alloc, i16, dx); + for ([_]i16{ 0, 0, 10, 0 }) |dy| try testAppendInt(&buf, alloc, i16, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 2), outline.contours.len); + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(@as(u16, 1), outline.contours[0]); + try testing.expectEqual(@as(u16, 3), outline.contours[1]); + try testing.expectEqual(@as(usize, 2), outline.contour(0).len); + try testing.expectEqual(@as(usize, 2), outline.contour(1).len); + try testing.expectEqual(outline.points[0..2].ptr, outline.contour(0).ptr); + try testing.expectEqual(outline.points[2..4].ptr, outline.contour(1).ptr); +} + +test "glyf: decode repeat and short vector flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, -16, 16, 0); + try testAppendInt(&buf, alloc, u16, 3); // four points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x08 | 0x10); // on, x short positive, y short negative, repeat + try buf.append(alloc, 3); // repeat for the next three points + for ([_]u8{ 1, 2, 4, 8 }) |dx| try buf.append(alloc, dx); + for ([_]u8{ 1, 2, 4, 8 }) |dy| try buf.append(alloc, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 1, .y = -1, .on_curve = true }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 3, .y = -3, .on_curve = true }, outline.points[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = -7, .on_curve = true }, outline.points[2]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 15, .y = -15, .on_curve = true }, outline.points[3]); +} + +test "glyf: decode off curve and same coordinate flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 7, 9); + try testAppendInt(&buf, alloc, u16, 1); // two points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x10 | 0x20); // off curve, x same, y same + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x10 | 0x20); // on curve, short positive x/y + try buf.append(alloc, 7); // x delta + try buf.append(alloc, 9); // y delta + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = false }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = 9, .on_curve = true }, outline.points[1]); +} + +test "glyf: decode one-point contour" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x10 | 0x20); // on curve, x same, y same + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 1), outline.points.len); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(u16, 0), outline.contours[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = true }, outline.contour(0)[0]); +} + +test "glyf: decode contour ending at max point index" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, std.math.maxInt(u16)); // 65536 points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const flag = 0x01 | 0x10 | 0x20; // on curve, x same, y same + var remaining: usize = @as(usize, std.math.maxInt(u16)) + 1; + while (remaining > 0) { + const run = @min(remaining, 256); + if (run == 1) { + try buf.append(alloc, flag); + } else { + try buf.append(alloc, flag | 0x08); // repeat + try buf.append(alloc, @intCast(run - 1)); + } + remaining -= run; + } + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 65536), outline.points.len); + try testing.expectEqual(@as(usize, 65536), outline.contour(0).len); +} + test "glyf: reject glyphs with instructions and composite glyphs" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; const test_font = @import("../embedded.zig").jetbrains_mono; @@ -496,6 +894,10 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.InstructionsNotSupported, glyph_notdef.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.InstructionsNotSupported, + glyph_notdef.decode(alloc), + ); // Glyph "Á" is at index 2. const len_Aacute, const glyph_Aacute = try getGlyph(font, 2); @@ -505,9 +907,16 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.CompositeNotSupported, glyph_Aacute.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.CompositeNotSupported, + glyph_Aacute.decode(alloc), + ); } test "glyf: reject truncated" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -522,9 +931,13 @@ test "glyf: reject truncated" { // it before the full length (which is 228 bytes). glyph_nul.data = glyph_nul.data[0 .. 227 - @sizeOf(Glyf.Entry.Header)]; try testing.expectError(Glyf.Entry.SizeError.EndOfStream, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.EndOfStream, glyph_nul.decode(alloc)); } test "glyf: reject endpoints out of order" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -544,9 +957,16 @@ test "glyf: reject endpoints out of order" { // copied, we can just const cast it back to mutable lol. std.mem.bytesAsSlice(u16, @as([]u8, @constCast(glyph_nul.data)))[3] = 0; try testing.expectError(Glyf.Entry.SizeError.EndPointsOutOfOrder, glyph_nul.size()); + try testing.expectError( + Glyf.Entry.DecodeError.EndPointsOutOfOrder, + glyph_nul.decode(alloc), + ); } test "glyf: reject too many points" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -568,10 +988,12 @@ test "glyf: reject too many points" { @as([]u8, @constCast(glyph_nul.data))[107] |= 0x08; @as([]u8, @constCast(glyph_nul.data))[108] = 0xFF; try testing.expectError(Glyf.Entry.SizeError.TooManyPoints, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.TooManyPoints, glyph_nul.decode(alloc)); } test "glyf: zero-contour glyph can be header-only" { const testing = std.testing; + const alloc = testing.allocator; const header: Glyf.Entry.Header = .{ .numberOfContours = 0, @@ -582,4 +1004,43 @@ test "glyf: zero-contour glyph can be header-only" { }; const glyph = try Glyf.Entry.init(std.mem.asBytes(&header)); try testing.expectEqual(@sizeOf(Glyf.Entry.Header), try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph can include instruction length" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectEqual(@sizeOf(Glyf.Entry.Header) + 2, try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph rejects instructions" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 1); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectError(Glyf.Entry.SizeError.InstructionsNotSupported, glyph.size()); + try testing.expectError(Glyf.Entry.DecodeError.InstructionsNotSupported, glyph.decode(alloc)); } diff --git a/vendor/libghostty-vt/src/font/opentype/sfnt.zig b/vendor/libghostty-vt/src/font/opentype/sfnt.zig index 9373cda0..55a9a0bd 100644 --- a/vendor/libghostty-vt/src/font/opentype/sfnt.zig +++ b/vendor/libghostty-vt/src/font/opentype/sfnt.zig @@ -285,6 +285,9 @@ pub const SFNT = struct { const native_endian = @import("builtin").target.cpu.arch.endian(); test "parse font" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -298,6 +301,9 @@ test "parse font" { } test "get table" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/font/shaper/coretext.zig b/vendor/libghostty-vt/src/font/shaper/coretext.zig index 3f69af6d..8a9374bd 100644 --- a/vendor/libghostty-vt/src/font/shaper/coretext.zig +++ b/vendor/libghostty-vt/src/font/shaper/coretext.zig @@ -1333,7 +1333,7 @@ test "shape emoji width long" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig index a400ecaf..996aabff 100644 --- a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig +++ b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig @@ -811,7 +811,7 @@ test "shape emoji width long" { ); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/sprite/Face.zig b/vendor/libghostty-vt/src/font/sprite/Face.zig index 596a9204..fd029d13 100644 --- a/vendor/libghostty-vt/src/font/sprite/Face.zig +++ b/vendor/libghostty-vt/src/font/sprite/Face.zig @@ -176,7 +176,7 @@ pub fn renderGlyph( alloc: Allocator, atlas: *font.Atlas, cp: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { if (std.debug.runtime_safety) { if (!self.hasCodepoint(cp, null)) { @@ -205,7 +205,16 @@ pub fn renderGlyph( else => |width| metrics.cell_width * width, }; - const height = metrics.cell_height; + // Sprite glyphs generally get the full cell height, but cursor glyphs need + // to be affected by `adjust-cursor-height`, so we use `cursor_height` for + // the height if it's one of the full-height cursors. + const height = switch (cp) { + @intFromEnum(Sprite.cursor_rect), + @intFromEnum(Sprite.cursor_hollow_rect), + @intFromEnum(Sprite.cursor_bar), + => metrics.cursor_height, + else => metrics.cell_height, + }; const padding_x = width / 4; const padding_y = height / 4; @@ -219,11 +228,32 @@ pub fn renderGlyph( // Write the drawing to the atlas const region = try canvas.writeAtlas(alloc, atlas); + // The X offset is the displacement from the left edge of the cell that our + // drawn sprite will be drawn in the grid. That's the same as the distance + // we have calculated from the left of the canvas, minus the padding, since + // the padding represents extra pixels to the left of the cell. + const offset_x: i32 = + @as(i32, @intCast(canvas.clip_left)) - + @as(i32, @intCast(padding_x)); + // Similar logic applies for the Y offset, but with the additional factor + // that we want to re-center cursor glyphs in the cell if they were drawn + // taller or shorter than a normal cell is; this only applies to cursors + // currently, but could conceivably apply to other things in the future. + const offset_y: i32 = + @as(i32, @intCast(region.height +| canvas.clip_bottom)) - + @as(i32, @intCast(padding_y)) + + @divTrunc( + // By adding half the difference between the cell height and the + // height we passed to the draw function, we center it in the cell. + @as(i32, @intCast(metrics.cell_height)) - @as(i32, @intCast(height)), + 2, + ); + return .{ .width = region.width, .height = region.height, - .offset_x = @as(i32, @intCast(canvas.clip_left)) - @as(i32, @intCast(padding_x)), - .offset_y = @as(i32, @intCast(region.height +| canvas.clip_bottom)) - @as(i32, @intCast(padding_y)), + .offset_x = offset_x, + .offset_y = offset_y, .atlas_x = region.x, .atlas_y = region.y, }; @@ -535,6 +565,92 @@ test "sprite face render all sprites" { try std.testing.expect(!diff); // There should be no diffs from reference. } +test "full height cursor sprites respect cursor height metric" { + const testing = std.testing; + const alloc = testing.allocator; + + var atlas: font.Atlas = try .init(alloc, 128, .grayscale); + defer atlas.deinit(alloc); + + // face with a 8x16 cell dimension. + var face: Face = .{ + .metrics = .calc(.{ + // Fudged number, not used in anything we care about here. + .px_per_em = 16, + + .cell_width = 8.0, + .ascent = 12.0, + .descent = -4.0, + .line_gap = 0.0, + }), + }; + + try testing.expectEqual(16, face.metrics.cell_height); + + // --- smaller than cell height --- + face.metrics.cursor_height = 12; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + + // --- equal to the cell height --- + face.metrics.cursor_height = 16; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + + // --- greater than the cell height --- + face.metrics.cursor_height = 20; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } +} + // test "sprite face print all sprites" { // std.debug.print("\n\n", .{}); // inline for (ranges) |range| { diff --git a/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png Binary files differnew file mode 100644 index 00000000..f5f8fb81 --- /dev/null +++ b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png diff --git a/vendor/libghostty-vt/src/inspector/widgets/page.zig b/vendor/libghostty-vt/src/inspector/widgets/page.zig index 844abc35..e332ab3c 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/page.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/page.zig @@ -25,8 +25,9 @@ pub fn inspector(page: *const terminal.Page) void { /// the tree node is open or not. If it is open you must close it with /// TreePop. pub fn treeNode(state: struct { - /// The page - page: *const terminal.Page, + /// Page dimensions available without reading its backing memory. + cols: terminal.size.CellCountInt, + rows: terminal.size.CellCountInt, /// The index of the page in a page list, used for headers. index: usize, /// The range of rows this page covers, inclusive. @@ -34,6 +35,10 @@ pub fn treeNode(state: struct { /// Whether this page is the active or viewport node. active: bool, viewport: bool, + /// Whether the page backing memory is currently compressed. + compressed: bool, + /// Dirty state is unavailable without restoring a compressed page. + dirty: ?bool, }) bool { // Setup our node. const open = open: { @@ -61,8 +66,8 @@ pub fn treeNode(state: struct { // Metadata cimgui.c.ImGui_TextDisabled( "%dc x %dr", - state.page.size.cols, - state.page.size.rows, + state.cols, + state.rows, ); cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); @@ -76,7 +81,11 @@ pub fn treeNode(state: struct { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); } - if (state.page.isDirty()) { + if (state.compressed) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "compressed"); + } + if (state.dirty orelse false) { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); } diff --git a/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig b/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig index bedbfb59..7e001289 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig @@ -1,12 +1,23 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../../terminal/main.zig"); +const pagepkg = @import("../../terminal/page.zig"); const stylepkg = @import("../../terminal/style.zig"); const widgets = @import("../widgets.zig"); -const units = @import("../units.zig"); const PageList = terminal.PageList; +/// Cell pointers resolved against a Node.PreservedPage rather than the +/// PageList node. +/// Omitting the node makes it impossible for inspector helpers to +/// accidentally restore the original compressed page. +const InspectedCell = struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + row_idx: terminal.size.CellCountInt, + col_idx: terminal.size.CellCountInt, +}; + /// PageList inspector widget. pub const Inspector = struct { pub const empty: Inspector = .{}; @@ -26,6 +37,13 @@ pub const Inspector = struct { } if (cimgui.c.ImGui_CollapsingHeader( + "Page Compression", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + compressionTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( "Scrollbar & Regions", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { @@ -58,8 +76,10 @@ pub const Inspector = struct { var index: usize = pages.totalPages(); var node = pages.pages.last; while (node) |page_node| : (node = page_node.prev) { - const page = &page_node.data; - row_offset -= page.size.rows; + const rows = page_node.rows(); + const resident = page_node.pageIfResident(); + const compressed = page_node.storage() == .compressed; + row_offset -= rows; index -= 1; // We use our location as the ID so that even if reallocations @@ -69,14 +89,29 @@ pub const Inspector = struct { // Open up the tree node. if (!widgets.page.treeNode(.{ - .page = page, + .cols = page_node.cols(), + .rows = rows, .index = index, - .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .row_range = .{ row_offset, row_offset + rows - 1 }, .active = node == active_pin.node, .viewport = node == viewport_pin.node, + .compressed = compressed, + .dirty = if (resident) |page| page.isDirty() else null, })) continue; defer cimgui.c.ImGui_TreePop(); - widgets.page.inspector(page); + + // Decode compressed contents into a temporary page. The + // original node stays compressed while its entry is open. + var preserved = page_node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled( + "(unable to copy compressed page)", + ); + continue; + }; + defer preserved.deinit(); + widgets.page.inspector(preserved.page()); } } } @@ -106,7 +141,7 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total number of pages in the linked list."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.totalPages()); + cimgui.c.ImGui_Text("%zu", pages.totalPages()); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -114,34 +149,21 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total rows represented by scrollback + active area."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.total_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Page Bytes"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Total bytes allocated for active pages."); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.page_size), - ); + cimgui.c.ImGui_Text("%zu", pages.total_rows); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Max Size"); + cimgui.c.ImGui_Text("Scrollback Limit"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker( - \\Maximum bytes before pages must be evicated. The total - \\used bytes may be higher due to minimum individual page - \\sizes but the next allocation that would exceed this limit - \\will evict pages from the front of the list to free up space. + \\Maximum uncompressed logical page memory before the oldest + \\history is evicted. Minimum page allocation sizes can make + \\current usage temporarily exceed this value. ); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.maxSize()), - ); + var limit_buf: [64]u8 = undefined; + const limit = formatBytes(&limit_buf, pages.maxSize()); + cimgui.c.ImGui_TextUnformatted(limit.ptr); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -150,14 +172,142 @@ fn summaryTable(pages: *const PageList) void { widgets.helpMarker("Current viewport anchoring mode."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); +} + +fn compressionTable(pages: *const PageList) void { + const memory = pages.memoryStats(); + + if (!cimgui.c.ImGui_BeginTable( + "pagelist_compression", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + compressionTextRow( + "Platform Support", + "Whether this target can discard physical page memory while retaining " ++ + "its virtual address range. The scrollback-compression setting may " ++ + "still disable automatic compression on a supported platform.", + if (terminal.compression_enabled) "supported" else "unsupported", + ); + var state_buf: [96]u8 = undefined; + const state = std.fmt.bufPrintZ( + &state_buf, + "{d} compressed, {d} resident", + .{ memory.compressed_pages, memory.resident_pages }, + ) catch unreachable; + compressionTextRow( + "Page States", + "Compressed pages retain an encoded allocation while their raw mapping " ++ + "is decommitted. Resident pages still have physical raw backing. " ++ + "Active, visible, and recently changed pages are expected to be resident.", + state, + ); + + var raw_buf: [64]u8 = undefined; + compressionTextRow( + "Uncompressed Size", + "Total size of all raw page mappings. This is the approximate page " ++ + "backing memory required if no pages were compressed and is also " ++ + "the virtual address space retained across compression.", + formatBytes(&raw_buf, memory.raw_bytes), + ); + + var encoded_buf: [64]u8 = undefined; + var compressed_raw_buf: [64]u8 = undefined; + var storage_buf: [160]u8 = undefined; + const storage = if (memory.decommitted_raw_bytes > 0) storage: { + const ratio = percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ); + break :storage std.fmt.bufPrintZ( + &storage_buf, + "{s} encoded / {s} raw ({d:.1}%)", + .{ + formatBytes(&encoded_buf, memory.encoded_bytes), + formatBytes( + &compressed_raw_buf, + memory.decommitted_raw_bytes, + ), + ratio, + }, + ) catch unreachable; + } else "none"; + compressionTextRow( + "Compressed Storage", + "Encoded allocation size compared with the original raw size of only " ++ + "the compressed pages. The percentage is the compression ratio, " ++ + "so smaller is better. Raw physical pages have been discarded.", + storage, + ); + + var resident_buf: [64]u8 = undefined; + compressionTextRow( + "Estimated Resident", + "Physical page backing estimated to remain after compression: resident " ++ + "raw allocations, including unused pool tails, plus encoded storage. " ++ + "Node and allocator metadata and unrelated terminal memory are excluded.", + formatBytes(&resident_buf, memory.estimatedResidentBytes()), + ); + + var savings_bytes_buf: [64]u8 = undefined; + var savings_buf: [128]u8 = undefined; + const savings = memory.estimatedSavings(); + const savings_text = std.fmt.bufPrintZ( + &savings_buf, + "{s} ({d:.1}% of raw page memory)", + .{ + formatBytes(&savings_bytes_buf, savings), + percentage(savings, memory.raw_bytes), + }, + ) catch unreachable; + compressionTextRow( + "Estimated Savings", + "Physical page backing avoided across the complete PageList. This is " ++ + "decommitted raw memory minus replacement encoded storage and is " ++ + "not a measurement of total process RSS.", + savings_text, + ); +} + +fn compressionTextRow( + label: [:0]const u8, + help: [:0]const u8, + value: [:0]const u8, +) void { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Tracked Pins"); + cimgui.c.ImGui_Text("%s", label.ptr); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Number of pins tracked for automatic updates."); + widgets.helpMarker(help); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); + cimgui.c.ImGui_TextUnformatted(value.ptr); +} + +fn formatBytes(buf: []u8, bytes: usize) [:0]const u8 { + if (bytes >= 1024 * 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / (1024 * 1024); + return std.fmt.bufPrintZ(buf, "{d:.2} MiB", .{value}) catch unreachable; + } + + if (bytes >= 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / 1024; + return std.fmt.bufPrintZ(buf, "{d:.1} KiB", .{value}) catch unreachable; + } + + return std.fmt.bufPrintZ(buf, "{d} B", .{bytes}) catch unreachable; +} + +fn percentage(numerator: usize, denominator: usize) f64 { + if (denominator == 0) return 0; + return 100 * + @as(f64, @floatFromInt(numerator)) / + @as(f64, @floatFromInt(denominator)); } fn scrollbarInfo(pages: *PageList) void { @@ -321,11 +471,18 @@ fn trackedPinsTable(pages: *const PageList) void { } _ = cimgui.c.ImGui_TableSetColumnIndex(3); - const dirty = pin.isDirty(); - if (dirty) { - cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + if (pin.node.pageIfResident()) |page| { + const dirty = page.dirty or + page.getRowAndCell(pin.x, pin.y).row.dirty; + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } } else { - cimgui.c.ImGui_TextDisabled("clean"); + // Dirty state lives in the discarded mapping. Keep inspector + // traversal metadata-only rather than restoring this page. + cimgui.c.ImGui_TextDisabled("compressed"); } _ = cimgui.c.ImGui_TableSetColumnIndex(4); @@ -540,17 +697,37 @@ pub const CellChooser = struct { .history => terminal.Point{ .history = self.lookup_coord }, }; - const cell = pages.getCell(pt) orelse { + const pin = pages.pin(pt) orelse { cimgui.c.ImGui_TextDisabled("(cell out of range)"); return; }; - self.cell_info.draw(cell, pt); + // Cell pointers must come from the same page whose auxiliary tables + // we inspect. For compressed nodes this is an independent preserved + // page, leaving the PageList node compressed across inspector frames. + var preserved = pin.node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled("(unable to copy compressed page)"); + return; + }; + defer preserved.deinit(); + + const page = preserved.page(); + const rac = page.getRowAndCell(pin.x, pin.y); + const cell: InspectedCell = .{ + .row = rac.row, + .cell = rac.cell, + .row_idx = pin.y, + .col_idx = pin.x, + }; + + self.cell_info.draw(cell, pt, page); if (cell.cell.style_id != stylepkg.default_id) { cimgui.c.ImGui_SeparatorText("Style"); - const style = cell.node.data.styles.get( - cell.node.data.memory, + const style = page.styles.get( + page.memory, cell.cell.style_id, ).*; widgets.style.table(style, null); @@ -558,12 +735,12 @@ pub const CellChooser = struct { if (cell.cell.hyperlink) { cimgui.c.ImGui_SeparatorText("Hyperlink"); - hyperlinkTable(cell); + hyperlinkTable(cell, page); } if (cell.cell.hasGrapheme()) { cimgui.c.ImGui_SeparatorText("Grapheme"); - graphemeTable(cell); + graphemeTable(cell, page); } } }; @@ -577,7 +754,7 @@ fn maxCoord( return br_point.coord(); } -fn hyperlinkTable(cell: PageList.Cell) void { +fn hyperlinkTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_hyperlink", 2, @@ -585,7 +762,6 @@ fn hyperlinkTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const link_id = page.lookupHyperlink(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -632,7 +808,7 @@ fn hyperlinkTable(cell: PageList.Cell) void { cimgui.c.ImGui_Text("%d", refs); } -fn graphemeTable(cell: PageList.Cell) void { +fn graphemeTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_grapheme", 2, @@ -640,7 +816,6 @@ fn graphemeTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const cps = page.lookupGrapheme(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -680,8 +855,9 @@ pub const CellInfo = struct { pub fn draw( _: *const CellInfo, - cell: PageList.Cell, + cell: InspectedCell, point: terminal.Point, + page: *const terminal.Page, ) void { if (!cimgui.c.ImGui_BeginTable( "cell_info", @@ -747,7 +923,7 @@ pub const CellInfo = struct { _ = cimgui.c.ImGui_TableSetColumnIndex(2); if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { defer cimgui.c.ImGui_EndListBox(); - if (cell.node.data.lookupGrapheme(cell.cell)) |cps| { + if (page.lookupGrapheme(cell.cell)) |cps| { var buf: [96]u8 = undefined; for (cps) |cp| { const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; @@ -845,7 +1021,7 @@ pub const CellInfo = struct { widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0; + const link_id = page.lookupHyperlink(cell.cell) orelse 0; cimgui.c.ImGui_Text("id=%d", link_id); } } diff --git a/vendor/libghostty-vt/src/inspector/widgets/screen.zig b/vendor/libghostty-vt/src/inspector/widgets/screen.zig index bfe961f4..41c113e7 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/screen.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/screen.zig @@ -393,7 +393,7 @@ pub fn semanticPromptTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (semantic_prompt.click) { .none => cimgui.c.ImGui_TextDisabled("(none)"), - .click_events => cimgui.c.ImGui_Text("click_events"), + .click_events => |click_events| cimgui.c.ImGui_Text("click_events=%s", @tagName(click_events).ptr), .cl => |cl| cimgui.c.ImGui_Text("cl=%s", @tagName(cl).ptr), } } diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index 6d4406e8..e01cdbb8 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -71,6 +71,7 @@ pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; +pub const SelectionGesture = terminal.SelectionGesture; pub const size_report = terminal.size_report; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; @@ -132,6 +133,14 @@ pub const input = struct { pub const encodeMouse = mouse_encode.encode; }; +/// Unicode utilities that match the terminal's text layout semantics. +pub const unicode = struct { + const unicode_pkg = @import("unicode/main.zig"); + + pub const codepointWidth = unicode_pkg.codepointWidth; + pub const graphemeWidth = unicode_pkg.graphemeWidth; +}; + comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. @@ -182,10 +191,13 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); + @export(&c.color_scheme_report_encode, .{ .name = "ghostty_color_scheme_report_encode" }); @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.unicode_codepoint_width, .{ .name = "ghostty_unicode_codepoint_width" }); + @export(&c.unicode_grapheme_width, .{ .name = "ghostty_unicode_grapheme_width" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @@ -196,6 +208,16 @@ comptime { @export(&c.row_get, .{ .name = "ghostty_row_get" }); @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); + @export(&c.color_contrast, .{ .name = "ghostty_color_contrast" }); + @export(&c.color_luminance, .{ .name = "ghostty_color_luminance" }); + @export(&c.color_parse, .{ .name = "ghostty_color_parse" }); + @export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" }); + @export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" }); + @export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" }); + @export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" }); + @export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" }); + @export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" }); + @export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); @@ -213,6 +235,8 @@ comptime { @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); + @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); + @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); @@ -237,6 +261,8 @@ comptime { @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); + @export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" }); + @export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @@ -340,6 +366,7 @@ test { _ = terminal; _ = @import("lib/main.zig"); @import("std").testing.refAllDecls(input); + @import("std").testing.refAllDecls(unicode); if (comptime terminal.options.c_abi) { _ = terminal.c_api; } diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig index 22cae8fe..8693458f 100644 --- a/vendor/libghostty-vt/src/renderer/State.zig +++ b/vendor/libghostty-vt/src/renderer/State.zig @@ -1,6 +1,9 @@ //! This is the render state that is given to a renderer. +const State = @This(); + const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Inspector = @import("../inspector/main.zig").Inspector; const terminalpkg = @import("../terminal/main.zig"); @@ -30,6 +33,76 @@ preedit: ?Preedit = null, /// need about the mouse. mouse: Mouse = .{}, +/// The number of threads currently waiting to acquire `mutex` via +/// `lockDemand`. This is not protected by the mutex; it is read by +/// hot lock/unlock loops (the IO parse thread) in `yieldToDemand` to +/// decide whether to hand the mutex off before relocking it. +demand: std.atomic.Value(u32) = .init(0), + +/// Handoff generation counter. Incremented (with a futex wake) by +/// `unlockDemand` after a demanding waiter releases the mutex, so that +/// `yieldToDemand` knows the waiter had its turn. +handoff_gen: std.atomic.Value(u32) = .init(0), + +/// How long `yieldToDemand` sleeps waiting for a demanding waiter to +/// take its turn before giving up. This bounds how long the IO parse +/// thread can stall if a wake is lost or the waiter is descheduled; a +/// demanding critical section (the renderer's frame snapshot) is +/// microseconds, so one millisecond is generous. +const handoff_timeout_ns = 1 * std.time.ns_per_ms; + +/// Acquire `mutex` while signaling demand for it. Use this instead of +/// locking the mutex directly on threads that must not be starved by +/// a hot lock/unlock loop (the renderer's frame snapshot). Must be +/// released with `unlockDemand`; releasing with `mutex.unlock` keeps +/// the data safe but makes parked `yieldToDemand` callers wait out +/// their full timeout. +/// +/// Both `std.Thread.Mutex` and os_unfair_lock are unfair: a running +/// thread that unlocks and immediately relocks beats a sleeping +/// waiter every time, because the waiter must first be woken and +/// scheduled. Under sustained pty output the IO parse thread is +/// exactly such a loop, so without this signal the renderer can +/// starve for as long as the output lasts. +pub fn lockDemand(self: *State) void { + _ = self.demand.fetchAdd(1, .monotonic); + self.mutex.lock(); + const prev = self.demand.fetchSub(1, .monotonic); + assert(prev > 0); +} + +/// Release `mutex` acquired via `lockDemand` and notify hot loops +/// parked in `yieldToDemand` that the demanding waiter had its turn. +pub fn unlockDemand(self: *State) void { + self.mutex.unlock(); + _ = self.handoff_gen.fetchAdd(1, .monotonic); + std.Thread.Futex.wake(&self.handoff_gen, 1); +} + +/// Called by hot lock/unlock loops between critical sections, with +/// `mutex` NOT held: if a `lockDemand` waiter exists, sleep until it +/// has acquired and released the mutex (or the timeout passes). This +/// is the handoff that unfair mutexes never do on their own. +/// +/// The orderings here are all monotonic because these atomics are a +/// scheduling heuristic, not a synchronization boundary: the mutex +/// itself orders the protected data, and the timeout bounds any +/// staleness. +pub fn yieldToDemand(self: *State) void { + if (self.demand.load(.monotonic) == 0) return; + + // Snapshot the generation before rechecking demand: if the waiter + // acquires and releases between our check and the wait below, the + // generation no longer matches and timedWait returns immediately. + const gen = self.handoff_gen.load(.monotonic); + if (self.demand.load(.monotonic) == 0) return; + std.Thread.Futex.timedWait( + &self.handoff_gen, + gen, + handoff_timeout_ns, + ) catch {}; +} + pub const Mouse = struct { /// The point on the viewport where the mouse currently is. We use /// viewport points to avoid the complexity of mapping the mouse to diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig index 48864219..e397a55a 100644 --- a/vendor/libghostty-vt/src/renderer/Thread.zig +++ b/vendor/libghostty-vt/src/renderer/Thread.zig @@ -10,6 +10,7 @@ const internal_os = @import("../os/main.zig"); const rendererpkg = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); +const terminalpkg = @import("../terminal/main.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const App = @import("../App.zig"); @@ -72,6 +73,9 @@ cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, +/// Incremental scrollback compression scheduling. +compression: Compression = undefined, + /// The surface we're rendering to. surface: *apprt.Surface, @@ -111,10 +115,12 @@ flags: packed struct { pub const DerivedConfig = struct { custom_shader_animation: configpkg.CustomShaderAnimation, + scrollback_compression: bool, pub fn init(config: *const configpkg.Config) DerivedConfig { return .{ .custom_shader_animation = config.@"custom-shader-animation", + .scrollback_compression = config.@"scrollback-compression", }; } }; @@ -162,7 +168,7 @@ pub fn init( var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return .{ + var result: Thread = .{ .alloc = alloc, .config = .init(config), .loop = loop, @@ -178,6 +184,14 @@ pub fn init( .mailbox = mailbox, .app_mailbox = app_mailbox, }; + + // Only enable compression if we have it enabled... save some + // minor resources. + if (comptime terminalpkg.compression_enabled) { + result.compression = try .init(); + } + + return result; } /// Clean up the thread. This is only safe to call once the thread @@ -189,6 +203,8 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); + if (comptime terminalpkg.compression_enabled) + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -492,6 +508,16 @@ fn drainMailbox(self: *Thread) !void { } fn changeConfig(self: *Thread, config: *const DerivedConfig) !void { + // A newly enabled scheduler must reconsider existing history even when no + // terminal activity occurred while compression was disabled. + if (comptime terminalpkg.compression_enabled) { + if (!self.config.scrollback_compression and + config.scrollback_compression) + { + self.compression.activity = null; + } + } + self.config = config.*; } @@ -537,6 +563,10 @@ fn wakeupCallback( // Render immediately _ = renderCallback(t, undefined, undefined, {}); + // PageList mutations maintain their own compression dirty state. Checking + // it here covers output, resize, and viewport scrolling uniformly. + t.compression.wake(t); + // The below is not used anymore but if we ever want to introduce // a configuration to introduce a delay to coalesce renders, we can // use this. @@ -723,3 +753,113 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } + +/// Schedules incremental terminal compression after renderer activity stops. +/// +/// This owns all renderer-specific compression state. The terminal decides +/// when compression-relevant activity changes and performs the actual work; +/// the renderer only provides idle scheduling and avoids waiting for the +/// terminal lock. +const Compression = struct { + const idle_interval = 250; + const step_interval = 1; + + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + activity: ?u64 = null, + + fn init() !Compression { + return .{ .timer = try xev.Timer.init() }; + } + + fn deinit(self: *Compression) void { + self.timer.deinit(); + } + + /// Start or postpone compression after a renderer wake. + fn wake(self: *Compression, thread: *Thread) void { + // If we have no compression then don't do anything. + if (comptime !terminalpkg.compression_enabled) return; + if (!thread.config.scrollback_compression) return; + + // PageList activity, rather than a generic renderer wake, restarts the + // idle interval. In particular, the inspector wakes the renderer every + // frame without changing terminal contents and must not starve this + // timer indefinitely. + if (thread.state.mutex.tryLock()) { + defer thread.state.mutex.unlock(); + const activity = thread.state.terminal.compressionActivity(); + if (self.activity == activity) return; + self.activity = activity; + } else if (self.completion.state() == .active) { + // Contention doesn't prove that compression-relevant activity + // changed. Keep an existing deadline so frequent inspector frames + // cannot postpone compression forever. The timer rechecks both the + // activity token and lock availability before doing any work. + return; + } + + // Contention may mean parsing is active. Scheduling is a harmless + // false positive when no compression work is actually pending, but is + // necessary when no timer is already active. + self.schedule(thread, idle_interval); + } + + /// Start the one-shot timer, or move its deadline if it is already active. + fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { + self.timer.reset( + &thread.loop, + &self.completion, + &self.reset_completion, + delay_ms, + Thread, + thread, + timerCallback, + ); + } + + fn timerCallback( + thread_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + result: xev.Timer.RunError!void, + ) xev.CallbackAction { + _ = result catch |err| switch (err) { + error.Canceled => return .disarm, + else => { + log.warn("error in compression timer err={}", .{err}); + return .disarm; + }, + }; + + const thread = thread_ orelse return .disarm; + const self = &thread.compression; + + if (self.step(thread)) |delay| self.schedule(thread, delay); + return .disarm; + } + + /// Try one bounded step without waiting for the terminal lock. The return + /// value is the delay before another attempt, or null when work is done. + fn step(self: *Compression, thread: *Thread) ?u64 { + if (!thread.config.scrollback_compression) return null; + + const state = thread.state; + if (!state.mutex.tryLock()) return idle_interval; + defer state.mutex.unlock(); + + const activity = state.terminal.compressionActivity(); + if (self.activity != activity) { + self.activity = activity; + return idle_interval; + } + + return switch (state.terminal.compress(.incremental)) { + .pending => step_interval, + .unsupported, + .complete, + => null, + }; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig index 0f4a294b..14b79036 100644 --- a/vendor/libghostty-vt/src/renderer/generic.zig +++ b/vendor/libghostty-vt/src/renderer/generic.zig @@ -1170,8 +1170,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us }); // } - state.mutex.lock(); - defer state.mutex.unlock(); + // Lock while signaling demand so the IO parse thread + // can't starve us. See renderer.State.lockDemand. + state.lockDemand(); + defer state.unlockDemand(); // If we're in a synchronized output state, we pause all rendering. if (state.terminal.modes.get(.synchronized_output)) { @@ -1199,8 +1201,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state.terminal.scrollViewport(.bottom); } - // Update our terminal state - try self.terminal_state.update(self.alloc, state.terminal); + // Begin the update of our terminal state. Work that + // doesn't require terminal access (e.g. style + // denormalization) is deferred to the endUpdate call + // outside of this critical section, keeping our lock + // hold time as short as possible. + try self.terminal_state.beginUpdate( + self.alloc, + state.terminal, + ); // If our terminal state is dirty at all we need to redo // the viewport search. @@ -1279,6 +1288,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + // Outside the critical area, complete the update we began + // within it. This must be done before anything reads the + // render state (e.g. rebuildCells). + self.terminal_state.endUpdate(); + // Outside the critical area we can update our links to contain // our regex results. self.config.links.renderCellMap( diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig index 442b7543..f8269354 100644 --- a/vendor/libghostty-vt/src/renderer/image.zig +++ b/vendor/libghostty-vt/src/renderer/image.zig @@ -192,9 +192,9 @@ pub const State = struct { return; }; - // For transmit time we always just use the current time - // and overwrite the overlay. - const transmit_time = try std.time.Instant.now(); + // Overlays are always considered new content, so we take a + // fresh generation stamp to force replacing any existing one. + const generation = terminal.kitty.graphics.nextGeneration(); // Ensure we have space for our overlay placement. Do this before // we upload our image so we don't have to deal with cleaning @@ -207,7 +207,7 @@ pub const State = struct { try self.prepImage( alloc, .overlay, - transmit_time, + generation, pending, ); errdefer comptime unreachable; @@ -525,14 +525,14 @@ pub const State = struct { self: *State, alloc: Allocator, id: Id, - transmit_time: std.time.Instant, + generation: u64, pending: Image.Pending, ) PrepImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. + // If this image exists and its generation is the same it is the + // identical image so we don't need to send it to the GPU. const gop = try self.images.getOrPut(alloc, id); if (gop.found_existing and - gop.value_ptr.transmit_time.order(transmit_time) == .eq) + gop.value_ptr.generation == generation) { return; } @@ -570,7 +570,7 @@ pub const State = struct { if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, - .transmit_time = undefined, + .generation = 0, }; } else { gop.value_ptr.image.markForReplace( @@ -586,7 +586,7 @@ pub const State = struct { log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; - gop.value_ptr.transmit_time = transmit_time; + gop.value_ptr.generation = generation; } /// Prepare the provided Kitty image for upload to the GPU by copying its @@ -599,7 +599,7 @@ pub const State = struct { try self.prepImage( alloc, .{ .kitty = image.id }, - image.transmit_time, + image.generation, .{ .width = image.width, .height = image.height, @@ -688,7 +688,13 @@ pub const Id = union(enum) { /// The map used for storing images. pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, - transmit_time: std.time.Instant, + + /// The generation of the terminal image this was created from + /// (see terminal.kitty.graphics.Image.generation). Used to detect + /// staleness: a differing generation for the same ID means the + /// contents changed and the texture must be replaced. Zero is + /// never a valid stored generation so it marks "not yet uploaded". + generation: u64, }); /// The state for a single image that is to be rendered. diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig index c5de6157..11ecc87a 100644 --- a/vendor/libghostty-vt/src/renderer/link.zig +++ b/vendor/libghostty-vt/src/renderer/link.zig @@ -21,6 +21,20 @@ pub const Link = struct { pub fn deinit(self: *Link) void { self.regex.deinit(); } + + /// Returns true if this link's highlight condition matches the given mouse state. + fn active( + self: *const Link, + mouse_viewport: ?point.Coordinate, + mouse_mods: inputpkg.Mods, + ) bool { + return switch (self.highlight) { + .always => true, + .always_mods => |v| mouse_mods.equal(v), + .hover => mouse_viewport != null, + .hover_mods => |v| mouse_viewport != null and mouse_mods.equal(v), + }; + } }; /// A set of links. This provides a higher level API for renderers @@ -66,6 +80,14 @@ pub const Set = struct { // Fast path, not very likely since we have default links. if (self.links.len == 0) return; + // Determine if any links are active before building the string and + // byte-to-cell map. Those buffers scale with viewport size and this + // function runs during frame updates, so avoid allocating them when + // the current mouse/modifier state can't highlight any regex links. + for (self.links) |*link| { + if (link.active(mouse_viewport, mouse_mods)) break; + } else return; + // Convert our render state to a string + byte map. var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); @@ -80,20 +102,7 @@ pub const Set = struct { // Go through each link and see if we have any matches. for (self.links) |*link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. - switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - - // We check the hover points later. - .hover => if (mouse_viewport == null) continue, - .hover_mods => |v| { - if (mouse_viewport == null) continue; - if (!mouse_mods.equal(v)) continue; - }, - } + if (!link.active(mouse_viewport, mouse_mods)) continue; var offset: usize = 0; while (offset < str.len) { @@ -266,6 +275,65 @@ test "renderCellMap hover links" { } } +test "renderCellMap inactive links don't allocate" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .hover = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always_mods = .{ .ctrl = true } }, + }, + + .{ + .regex = "IJ", + .action = .{ .open = {} }, + .highlight = .{ .hover_mods = .{ .shift = true } }, + }, + }); + defer set.deinit(alloc); + + var failing = std.testing.FailingAllocator.init( + alloc, + .{ .fail_index = 0 }, + ); + const failing_alloc = failing.allocator(); + + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(failing_alloc); + try set.renderCellMap( + failing_alloc, + &result, + &state, + null, + .{}, + ); + + try testing.expectEqual(@as(usize, 0), result.count()); +} + test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu index b2731f8a..1f6716f9 100644 --- a/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -6,6 +6,7 @@ export module ghostty { # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration # feature flags into command options. + @complete external export def --wrapped ssh [...args] { if not ((has_feature "ssh-env") or (has_feature "ssh-terminfo")) { ^ssh ...$args @@ -24,6 +25,7 @@ export module ghostty { } # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable + @complete external export def --wrapped sudo [...args] { mut sudo_args = $args diff --git a/vendor/libghostty-vt/src/simd/vt.cpp b/vendor/libghostty-vt/src/simd/vt.cpp index 5bf4147d..15d46966 100644 --- a/vendor/libghostty-vt/src/simd/vt.cpp +++ b/vendor/libghostty-vt/src/simd/vt.cpp @@ -198,35 +198,55 @@ size_t DecodeUTF8(const uint8_t* HWY_RESTRICT input, return static_cast<size_t>(out - output); } -/// Decode the UTF-8 text in input into output until an escape -/// character is found. This returns the number of bytes consumed -/// from input and writes the number of decoded characters into -/// output_count. -/// -/// This may return a value less than count even with no escape -/// character if the input ends with an incomplete UTF-8 sequence. -/// The caller should check the next byte manually to determine -/// if it is incomplete. +// Widen the N uint8 lanes of v into N uint32 values stored at out. +// This is the UTF-8 to UTF-32 "decode" for ASCII bytes. template <class D> -size_t DecodeUTF8UntilControlSeqImpl(D d, - const T* HWY_RESTRICT input, - size_t count, - char32_t* output, - size_t* output_count) { - const size_t N = hn::Lanes(d); +static HWY_INLINE void WidenAsciiStore(D d, + hn::Vec<D> v, + char32_t* HWY_RESTRICT out) { + uint32_t* HWY_RESTRICT out32 = reinterpret_cast<uint32_t*>(out); +#if HWY_TARGET == HWY_SCALAR + // The scalar fallback target has single-lane vectors, which cannot + // be halved; widen the one lane directly. + (void)d; + out32[0] = hn::GetLane(v); +#else + const hn::Half<D> dh; + const hn::Half<hn::Half<D>> dq; + const hn::Rebind<uint32_t, decltype(dq)> d32; + const size_t N4 = hn::Lanes(dq); + const auto lo = hn::LowerHalf(dh, v); + const auto hi = hn::UpperHalf(dh, v); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, lo)), d32, out32 + 0 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, lo)), d32, out32 + 1 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, hi)), d32, out32 + 2 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, hi)), d32, out32 + 3 * N4); +#endif +} - // Create a vector containing ESC since that denotes a control sequence. +// The general (non-ASCII) portion of DecodeUTF8UntilControlSeqImpl. +// Continues scanning for ESC starting at byte offset `base` and decodes +// input[base..stop) via simdutf. The caller must have already decoded +// input[0..base) as ASCII into output[0..base) (one codepoint per byte). +template <class D> +static HWY_NOINLINE size_t DecodeNonAsciiUntilControlSeq( + D d, + const T* HWY_RESTRICT input, + size_t count, + size_t base, + char32_t* output, + size_t* output_count) { + const size_t N = hn::Lanes(d); const hn::Vec<D> esc_vec = Set(d, 0x1B); // Compare N elements at a time. - size_t i = 0; + size_t i = base; for (; i + N <= count; i += N) { // Load the N elements from our input into a vector. const hn::Vec<D> input_vec = hn::LoadU(d, input + i); // If we don't have any escapes we keep going. We want to accumulate // the largest possible valid UTF-8 sequence before decoding. - // TODO(mitchellh): benchmark this vs decoding every time const size_t esc_idx = IndexOfChunk(d, esc_vec, input_vec); if (esc_idx == kNotFound) { continue; @@ -235,25 +255,17 @@ size_t DecodeUTF8UntilControlSeqImpl(D d, // We have an ESC char, decode up to this point. We start by assuming // a valid UTF-8 sequence and slow-path into error handling if we find // an invalid sequence. - *output_count = DecodeUTF8(input, i + esc_idx, output); + *output_count = base + DecodeUTF8(input + base, i + esc_idx - base, + output + base); return i + esc_idx; } - // If we have leftover input then we decode it one byte at a time (slow!) + // If we have leftover input then we scan it one byte at a time (slow!) // using pretty much the same logic as above. - if (i != count) { - const hn::CappedTag<T, 1> d1; - using D1 = decltype(d1); - const hn::Vec<D1> esc1 = Set(d1, hn::GetLane(esc_vec)); - for (; i < count; ++i) { - const hn::Vec<D1> input_vec = hn::LoadU(d1, input + i); - const size_t esc_idx = IndexOfChunk(d1, esc1, input_vec); - if (esc_idx == kNotFound) { - continue; - } - - *output_count = DecodeUTF8(input, i + esc_idx, output); - return i + esc_idx; + for (; i < count; ++i) { + if (input[i] == 0x1B) { + *output_count = base + DecodeUTF8(input + base, i - base, output + base); + return i; } } @@ -267,9 +279,86 @@ size_t DecodeUTF8UntilControlSeqImpl(D d, // like C0, C1, F5-FF should NOT be trimmed — they should be passed // through to DecodeUTF8 which will replace them with U+FFFD per the // maximal subpart algorithm. - const size_t trimmed_len = TrimValidPartialUTF8(input, i); - *output_count = DecodeUTF8(input, trimmed_len, output); - return trimmed_len; + const size_t trimmed_len = TrimValidPartialUTF8(input + base, count - base); + *output_count = base + DecodeUTF8(input + base, trimmed_len, output + base); + return base + trimmed_len; +} + +/// Decode the UTF-8 text in input into output until an escape +/// character is found. This returns the number of bytes consumed +/// from input and writes the number of decoded characters into +/// output_count. +/// +/// This may return a value less than count even with no escape +/// character if the input ends with an incomplete UTF-8 sequence. +/// The caller should check the next byte manually to determine +/// if it is incomplete. +template <class D> +size_t DecodeUTF8UntilControlSeqImpl(D d, + const T* HWY_RESTRICT input, + size_t count, + char32_t* output, + size_t* output_count) { + const size_t N = hn::Lanes(d); + + // Create a vector containing ESC since that denotes a control sequence. + const hn::Vec<D> esc_vec = Set(d, 0x1B); + // Any byte >= 0x80 is part of a multi-byte UTF-8 sequence. + const hn::Vec<D> high_vec = Set(d, 0x80); + + // ASCII fast path: terminal input is overwhelmingly ASCII, for which + // UTF-8 decoding is a simple widening of each byte to 32 bits. We + // fuse the ESC scan with the decode, one chunk at a time, and only + // fall back to the full UTF-8 decoder (simdutf) when we encounter a + // non-ASCII byte. This avoids a second pass over the input and, for + // the common short runs between escape sequences, avoids the fixed + // overhead of the general-purpose decoder. + size_t i = 0; + for (; i + N <= count; i += N) { + const hn::Vec<D> input_vec = hn::LoadU(d, input + i); + + // Find the first byte that stops the ASCII fast path: an ESC or + // any non-ASCII byte. + const hn::Mask<D> stop_mask = + hn::Or(hn::Eq(input_vec, esc_vec), hn::Ge(input_vec, high_vec)); + const intptr_t stop = hn::FindFirstTrue(d, stop_mask); + + // Widen the whole chunk unconditionally: output is guaranteed to + // be at least as large as input, and if we stop mid-chunk only + // the prefix is reported (the rest is scratch that the caller + // never reads). + WidenAsciiStore(d, input_vec, output + i); + if (stop < 0) continue; + + const size_t stop_idx = i + static_cast<size_t>(stop); + if (input[stop_idx] == 0x1B) { + // ESC: everything before it was ASCII, one codepoint per byte. + *output_count = stop_idx; + return stop_idx; + } + + // Non-ASCII: decode the rest (up to an ESC) with the full decoder. + return DecodeNonAsciiUntilControlSeq(d, input, count, stop_idx, output, + output_count); + } + + // Leftover input (< N bytes): process one byte at a time. + for (; i < count; ++i) { + const T b = input[i]; + if (b == 0x1B) { + *output_count = i; + return i; + } + if (b >= 0x80) { + return DecodeNonAsciiUntilControlSeq(d, input, count, i, output, + output_count); + } + output[i] = b; + } + + // The entire input was ASCII (no ESC, no partial sequences possible). + *output_count = count; + return count; } size_t DecodeUTF8UntilControlSeq(const uint8_t* HWY_RESTRICT input, diff --git a/vendor/libghostty-vt/src/simd/vt.zig b/vendor/libghostty-vt/src/simd/vt.zig index 4230665f..807285f7 100644 --- a/vendor/libghostty-vt/src/simd/vt.zig +++ b/vendor/libghostty-vt/src/simd/vt.zig @@ -80,13 +80,18 @@ fn utf8DecodeUntilControlSeqScalar( var valid: usize = 1; // lead byte is valid for (0..seq.len - 1) |ci| { if (decode_offset + valid >= decode.len) { - // Truncated at end of buffer: treat as incomplete - // input that may be completed later. Stop decoding - // without consuming these bytes. - return .{ + // The sequence is cut off by the end of the decode + // region. If the region ends at the true end of the + // input then it may be completed by future input, so + // stop without consuming these bytes. If the region + // was bounded by an ESC then the sequence can never + // be completed; the valid-so-far prefix is a maximal + // subpart which maps to a single U+FFFD below. + if (decode.len == input.len) return .{ .consumed = decode_offset, .decoded = decode_count, }; + break; } const cb = decode[decode_offset + valid]; if (cb < seq.ranges[ci][0] or cb > seq.ranges[ci][1]) { @@ -148,6 +153,58 @@ fn utf8SeqInfo(lead: u8) Utf8SeqInfo { }; } +// Differential test: the SIMD implementation must agree with the +// scalar implementation on any input. Exercises random mixtures of +// ASCII, escapes, controls, valid and invalid UTF-8, at various +// lengths (including chunk-boundary straddling cases). +test "decode simd matches scalar" { + if (comptime !options.simd) return error.SkipZigTest; + + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0xf00dface); + const rand = prng.random(); + + var input: [257]u8 = undefined; + var out_simd: [input.len]u32 = undefined; + var out_scalar: [input.len]u32 = undefined; + + for (0..10_000) |_| { + const len = rand.intRangeAtMost(usize, 0, input.len); + const style = rand.intRangeAtMost(u8, 0, 2); + for (input[0..len]) |*b| { + b.* = switch (style) { + // Mostly ASCII with occasional specials. + 0 => switch (rand.intRangeAtMost(u8, 0, 20)) { + 0 => 0x1B, + 1 => rand.intRangeAtMost(u8, 0, 0x1F), + 2 => rand.int(u8), + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Heavy multi-byte/invalid UTF-8. + 1 => switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => rand.intRangeAtMost(u8, 0x80, 0xBF), + 1 => rand.intRangeAtMost(u8, 0xC0, 0xFF), + 2 => 0x1B, + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Fully random bytes. + else => rand.int(u8), + }; + } + + const res_simd = utf8DecodeUntilControlSeq(input[0..len], &out_simd); + const res_scalar = utf8DecodeUntilControlSeqScalar(input[0..len], &out_scalar); + errdefer std.debug.print("input={x}\n", .{input[0..len]}); + try testing.expectEqual(res_scalar.consumed, res_simd.consumed); + try testing.expectEqual(res_scalar.decoded, res_simd.decoded); + try testing.expectEqualSlices( + u32, + out_scalar[0..res_scalar.decoded], + out_simd[0..res_simd.decoded], + ); + } +} + test "decode no escape" { const testing = std.testing; @@ -399,6 +456,35 @@ test "decode valid multibyte surrounded by invalid" { } } +test "decode partial UTF-8 before escape" { + const testing = std.testing; + + // A valid-so-far but incomplete sequence cut off by an ESC can + // never be completed, so it is consumed and replaced by a single + // U+FFFD (maximal subpart) rather than left pending. Only + // sequences cut off by the true end of input are left pending. + var output: [64]u32 = undefined; + + // 2-byte lead cut off by ESC. + { + const str = "hi\xc2\x1b[0m"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 3), result.consumed); + try testing.expectEqual(@as(usize, 3), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[2]); + } + + // 3-byte lead plus one valid continuation cut off by ESC: + // the whole prefix is one maximal subpart, one U+FFFD. + { + const str = "\xe0\xa0\x1bX"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 2), result.consumed); + try testing.expectEqual(@as(usize, 1), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[0]); + } +} + test "decode invalid byte before escape" { const testing = std.testing; diff --git a/vendor/libghostty-vt/src/synthetic/Kitty.zig b/vendor/libghostty-vt/src/synthetic/Kitty.zig new file mode 100644 index 00000000..c2c649d7 --- /dev/null +++ b/vendor/libghostty-vt/src/synthetic/Kitty.zig @@ -0,0 +1,88 @@ +//! Generates random Kitty graphics protocol APC sequences. +//! +//! The payload is random base64, NOT a valid image: decoding it as +//! PNG will fail. This corpus is meant for benchmarking the stream, +//! APC, and Kitty command parsing paths, which never decode the +//! image data. It does not exercise successful image loading. +const Kitty = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); +const Bytes = @import("Bytes.zig"); + +/// Random number generator. +rand: std.Random, + +/// The base64 payload length for each generated command. This is +/// rounded down to a multiple of four so the payload is always valid +/// base64 without requiring padding. Kitty clients typically chunk +/// payloads at 4096 bytes. +data_len: usize = 4096, + +fn checkBase64Alphabet(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '+', '/' => true, + else => false, + }; +} + +/// The base64 alphabet, without the padding character. +pub const base64_alphabet = Bytes.generateAlphabet(checkBase64Alphabet); + +pub fn generator(self: *Kitty) Generator { + return .init(self, next); +} + +const prefix = "\x1b_G"; +const st = "\x1b\\"; + +/// Get the next Kitty graphics APC sequence: a well-formed transmit +/// command with a random base64 payload (not a valid image; see the +/// module comment), including the APC prefix and the ST terminator. +pub fn next( + self: *Kitty, + writer: *std.Io.Writer, + max_len: usize, +) Generator.Error!void { + var control_buf: [64]u8 = undefined; + const control = std.fmt.bufPrint( + &control_buf, + "a=t,f=100,i={d};", + .{self.rand.intRangeAtMost(u32, 1, 1_000_000)}, + ) catch unreachable; + + const overhead = prefix.len + control.len + st.len; + assert(max_len > overhead); + + const avail = @min(self.data_len, max_len - overhead); + const payload_len = avail - (avail % 4); + + try writer.writeAll(prefix); + try writer.writeAll(control); + if (payload_len > 0) { + const bytes: Bytes = .{ + .rand = self.rand, + .alphabet = base64_alphabet, + .min_len = payload_len, + .max_len = payload_len, + }; + _ = try bytes.write(writer); + } + try writer.writeAll(st); +} + +test "kitty" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + + var buf: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var v: Kitty = .{ .rand = prng.random() }; + const gen = v.generator(); + try gen.next(&writer, buf.len); + + const data = writer.buffered(); + try testing.expect(std.mem.startsWith(u8, data, "\x1b_Ga=t,f=100,i=")); + try testing.expect(std.mem.endsWith(u8, data, "\x1b\\")); +} diff --git a/vendor/libghostty-vt/src/synthetic/cli.zig b/vendor/libghostty-vt/src/synthetic/cli.zig index d9b6a659..d6ec028f 100644 --- a/vendor/libghostty-vt/src/synthetic/cli.zig +++ b/vendor/libghostty-vt/src/synthetic/cli.zig @@ -7,6 +7,7 @@ const cli = @import("../cli.zig"); /// predictably named files under `cli/`. pub const Action = enum { ascii, + kitty, osc, utf8, @@ -21,6 +22,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .ascii => @import("cli/Ascii.zig"), + .kitty => @import("cli/Kitty.zig"), .osc => @import("cli/Osc.zig"), .utf8 => @import("cli/Utf8.zig"), }; diff --git a/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig b/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig new file mode 100644 index 00000000..99a92798 --- /dev/null +++ b/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig @@ -0,0 +1,70 @@ +const Kitty = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const synthetic = @import("../main.zig"); + +const log = std.log.scoped(.@"kitty-gen"); + +pub const Options = struct { + /// The base64 payload length of each command in bytes, rounded + /// down to a multiple of four. Kitty clients typically chunk + /// payloads at 4096 bytes. + @"data-len": usize = 4096, +}; + +opts: Options, + +/// The buffer a single generated sequence is written into. Sized to +/// the payload length plus room for the control data and terminator. +buf: []u8, + +/// Create a new Kitty graphics sequence generator for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*Kitty { + const ptr = try alloc.create(Kitty); + errdefer alloc.destroy(ptr); + + const buf = try alloc.alloc(u8, opts.@"data-len" + 128); + errdefer alloc.free(buf); + + ptr.* = .{ .opts = opts, .buf = buf }; + return ptr; +} + +pub fn destroy(self: *Kitty, alloc: Allocator) void { + alloc.free(self.buf); + alloc.destroy(self); +} + +pub fn run(self: *Kitty, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: synthetic.Kitty = .{ + .rand = rand, + .data_len = self.opts.@"data-len", + }; + + while (true) { + var fixed: std.Io.Writer = .fixed(self.buf); + try gen.next(&fixed, self.buf.len); + writer.writeAll(fixed.buffered()) catch |err| switch (err) { + error.WriteFailed => return, + }; + } +} + +test Kitty { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Kitty = try .create(alloc, .{}); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); +} diff --git a/vendor/libghostty-vt/src/synthetic/main.zig b/vendor/libghostty-vt/src/synthetic/main.zig index 85f9f7d3..1c26090d 100644 --- a/vendor/libghostty-vt/src/synthetic/main.zig +++ b/vendor/libghostty-vt/src/synthetic/main.zig @@ -18,6 +18,7 @@ pub const cli = @import("cli.zig"); pub const Generator = @import("Generator.zig"); pub const Bytes = @import("Bytes.zig"); pub const Utf8 = @import("Utf8.zig"); +pub const Kitty = @import("Kitty.zig"); pub const Osc = @import("Osc.zig"); test { diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig index 63155962..79aa96f1 100644 --- a/vendor/libghostty-vt/src/terminal/PageList.zig +++ b/vendor/libghostty-vt/src/terminal/PageList.zig @@ -12,8 +12,10 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); +const terminal_mem = @import("mem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -44,8 +46,251 @@ pub const List = DoublyLinkedList(Node); const Node = struct { prev: ?*Node = null, next: ?*Node = null, - data: Page, + data: Data, serial: u64, + + /// How the backing memory of the embedded Page was allocated. Pool-owned + /// memory is always a full standard-size item from the memory + /// pool, regardless of the page layout size. Heap-owned memory + /// is allocated directly with the page allocator and is exactly + /// `Page.memory.len` bytes. + /// + /// This must never be inferred from the memory length: heap-owned + /// pages can be smaller than the standard size (e.g. compacted + /// pages), and returning one to the pool would corrupt it. + /// + /// This has no default on purpose so that every construction site + /// is forced to make an explicit decision. + owned: Owned, + + /// The physical representation of the page contents. + /// + /// Both states retain the same `Page.memory` virtual mapping and the same + /// `Page` metadata. A resident page can access the mapping directly since + /// it is, well, resident. + /// + /// A compressed page owns an encoded copy while the physical memory + /// behind the raw mapping is discarded. HOWEVER, we retain the + /// virtual allocation so that restoration is infallible. + const Data = union(enum) { + resident: Page, + compressed: compression.Page, + }; + + const Owned = enum { pool, heap }; + + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + + /// Return the terminal page stored in this node. + /// + /// WARNING: This will DECOMPRESS compressed pages! Only use this if + /// you need access to the underlying memory. If you only need access to + /// metadata (row, col counts etc) then use the other metadata functions. + pub inline fn page(self: *Node) *Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => self.restore(.preserve), + }; + } + + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + + /// Return a page which the caller knows is already resident. + /// + /// This avoids the representation check in hot paths which already hold + /// live pointers into the page mapping. Such pointers are only valid while + /// the page is resident. Prefer `page` unless the caller can establish + /// that invariant independently. + pub inline fn pageAssumeResident(self: *Node) *Page { + return &self.data.resident; + } + + /// Return the number of populated rows without accessing page memory. + pub inline fn rows(self: *const Node) size.CellCountInt { + return self.metadata().size.rows; + } + + /// Return the current column count without accessing page memory. + pub inline fn cols(self: *const Node) size.CellCountInt { + return self.metadata().size.cols; + } + + /// Return the page capacity without accessing page memory. + pub inline fn capacity(self: *const Node) Capacity { + return self.metadata().capacity; + } + + /// Return the embedded Page metadata without restoring its memory. + inline fn metadata(self: *const Node) *const Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => |*page_| &page_.page, + }; + } + + const RestoreMode = enum { + /// Decode the compressed representation back into the raw mapping. + preserve, + + /// Discard the compressed representation without decoding it. The + /// caller must not read the page contents before overwriting them. + discard, + }; + + /// Restore this node to the resident representation. + /// + /// Preserve mode reconstructs the page contents and is used by `page`. + /// Discard mode skips decoding for callers which will overwrite or destroy + /// the page. Both modes recommit the retained mapping, free the encoded + /// allocation, and leave the node in a valid resident state. + noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page { + const compressed = switch (self.data) { + .resident => |*page_| return page_, + .compressed => |*page_| page_, + }; + + // Decommit only discarded the physical pages. Recommit prepares the + // still-reserved mapping for decoding or reuse by the caller. + terminal_mem.recommit(compressed.page.memory); + + const restored = switch (mode) { + .preserve => compressed.restore() catch |err| { + // Compressed nodes retain the immutable encoding and exact + // output mapping used to create them. Any decode error is an + // internal codec bug or memory corruption. Keep this switch + // exhaustive so new decoder errors require classification. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + log.err("failed to restore compressed page err={}", .{err}); + @panic("failed to restore compressed terminal page"); + }, + + .discard => compressed.page, + }; + + // Remove our compressed data + compressed.deinit(); + + // We're now a resident, non-compressed node. + self.data = .{ .resident = restored }; + return &self.data.resident; + } + + inline fn isCompressed(self: *const Node) bool { + return switch (self.data) { + .resident => false, + .compressed => true, + }; + } }; /// The memory pool we get page nodes from. @@ -120,29 +365,42 @@ pool: MemoryPool, /// The list of pages in the screen. pages: List, -/// A monotonically increasing serial number that is incremented each -/// time a page is allocated or reused as new. The serial is assigned to -/// the Node. +/// The next globally unique reference generation for this PageList. A +/// generation is assigned whenever a page is allocated, reused as new, or +/// changed in place such that existing page coordinates are no longer stable. /// /// The serial number can be used to detect whether the page is identical /// to the page that was originally referenced by a pointer. Since we reuse /// and pool memory, pointer stability is not guaranteed, but the serial -/// will always be different for different allocations. +/// will always be different for different page generations. /// /// Developer note: we never do overflow checking on this. If we created /// a new page every second it'd take 584 billion years to overflow. We're /// going to risk it. page_serial: u64, -/// The lowest still valid serial number that could exist. This allows -/// for quick comparisons to find invalid pages in references. -page_serial_min: u64, - -/// Byte size of the total amount of allocated pages. Note this does -/// not include the total allocated amount in the pool which may be more -/// than this due to preheating. +/// The first serial in the current whole-list validity epoch. Only `reset` +/// advances this value, immediately before rebuilding every page. It is not +/// the generation of the first page or the exact minimum live generation. +/// Page generations are not monotonic in list order: replacement and split +/// operations can put a fresh generation before older live pages. +/// +/// A generation below this epoch is definitely invalid, allowing O(1) +/// rejection in `nodeIsValid` and bulk removal of pre-reset search results. +/// A generation at or above it is only potentially valid and must still be +/// checked against the live list before its coordinates are used. +page_serial_epoch: u64, + +/// Byte size of the raw backing mappings owned by active page nodes. This is +/// logical scrollback accounting and does not change while a mapping is +/// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, +/// Continuation state for incremental page compression. This allows +/// compress(.incremental) to work. More details on all that there and +/// in the state struct. +page_compression: IncrementalCompressionState = .{}, + /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. @@ -403,7 +661,7 @@ pub fn init( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -444,13 +702,14 @@ fn initPages( // redundant here for safety. assert(layout.total_size <= size.max_page_size); - // If we have an error, we need to clean up our non-standard pages + // If we have an error, we need to clean up our heap-owned pages // since they're not in the pool. errdefer { var it = page_list.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -463,7 +722,9 @@ fn initPages( const page_buf = if (pooled) buf: { try tw.check(.page_buf_std); - break :buf try pool.pages.create(); + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; } else buf: { try tw.check(.page_buf_non_std); break :buf try page_alloc.alignedAlloc( @@ -488,11 +749,12 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - node.data.size.rows = @min(rem, node.data.capacity.rows); - rem -= node.data.size.rows; + node.page().size.rows = @min(rem, node.capacity().rows); + rem -= node.rows(); // Add the page to the list page_list.append(node); @@ -553,15 +815,14 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { { var node_ = self.pages.first; while (node_) |node| { - actual_total += node.data.size.rows; + actual_total += node.rows(); node_ = node.next; - // While doing this traversal, verify no node has a serial - // number lower than our min. - if (node.serial < self.page_serial_min) { + // Every live node must belong to the current validity epoch. + if (node.serial < self.page_serial_epoch) { log.warn( - "PageList integrity violation: page serial too low serial={} min={}", - .{ node.serial, self.page_serial_min }, + "PageList integrity violation: page serial predates epoch serial={} epoch={}", + .{ node.serial, self.page_serial_epoch }, ); return IntegrityError.PageSerialInvalid; } @@ -588,7 +849,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { offset -= self.viewport_pin.y; break :offset self.total_rows - offset; @@ -624,8 +885,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { } } -/// Deinit the pagelist. If you own the memory pool (used clonePool) then -/// this will reset the pool and retain capacity. +/// Deinit the pagelist, freeing all page memory and the memory pool. pub fn deinit(self: *PageList) void { // Verify integrity before cleanup self.assertIntegrity(); @@ -634,12 +894,14 @@ pub fn deinit(self: *PageList) void { self.tracked_pins.deinit(self.pool.alloc); // Go through our linked list and deallocate all pages that are - // not standard size. + // heap-owned (not in the pool). const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } @@ -658,10 +920,13 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); - // Invalidate all external page refs to the previous list. The reset below - // rebuilds the page list from the pools, so old untracked refs must be - // rejected before any validation attempts to inspect their node pointers. - self.page_serial_min = self.page_serial; + // Reset discards all scrollback, so there is nothing left to compress. + self.page_compression.reset(); + + // Begin a new whole-list validity epoch before rebuilding from the pools. + // Every old reference now has a serial below the epoch and can be rejected + // in O(1), even if the node pool later reuses its pointer address. + self.page_serial_epoch = self.page_serial; // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already @@ -678,14 +943,15 @@ pub fn reset(self: *PageList) void { ) catch unreachable; // Before resetting our pools we need to free any pages that - // are non-standard size since those were allocated outside - // the pool. + // are heap-owned since those were allocated outside the pool. { const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } } @@ -825,8 +1091,9 @@ pub fn clone( const page_alloc = pool.pages.arena.child_allocator; var page_it = page_list.first; while (page_it) |node| : (page_it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -840,25 +1107,27 @@ pub fn clone( // we don't know if the source page has a standard size. const node = try createPageExt( &pool, - chunk.node.data.capacity, + .{ .cap = chunk.node.capacity() }, &page_serial, &page_size, ); - assert(node.data.capacity.rows >= chunk.end - chunk.start); - defer node.data.assertIntegrity(); - node.data.size.rows = chunk.end - chunk.start; - node.data.size.cols = chunk.node.data.size.cols; - try node.data.cloneFrom( - &chunk.node.data, + const dst_page = node.page(); + const src_page = chunk.node.page(); + assert(node.capacity().rows >= chunk.end - chunk.start); + defer dst_page.assertIntegrity(); + dst_page.size.rows = chunk.end - chunk.start; + dst_page.size.cols = chunk.node.cols(); + try dst_page.cloneFrom( + src_page, chunk.start, chunk.end, ); - node.data.dirty = chunk.node.data.dirty; + dst_page.dirty = src_page.dirty; page_list.append(node); - total_rows += node.data.size.rows; + total_rows += node.rows(); // Remap our tracked pins by changing the page and // offsetting the Y position based on the chunk start. @@ -887,7 +1156,7 @@ pub fn clone( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -911,8 +1180,9 @@ pub fn clone( // now we rarely clone less than the active area and if we do // the area is by definition very small. const last = result.pages.last.?; - const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; - last.data.clearCells(row, 0, result.cols); + const page = last.page(); + const row = &page.rows.ptr(page.memory)[last.rows() - 1]; + page.clearCells(row, 0, result.cols); } // Update our total rows to be our row size. @@ -952,6 +1222,12 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + self.page_compression.markActivity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -1047,6 +1323,16 @@ fn resizeCols( const wrapped = wrapped: { var wrapped: usize = 0; + // If shrinking rows (in the .lt branch of resize, rows shrink + // before we get here) pushed the cursor pin above the new active + // area, there are no rows to count and iterating .left_up toward + // the active-area top would be an invalid (reversed) range. The + // preserved-cursor growth below already no-ops for a cursor that + // isn't in the active area, so we just count zero here. + if (active_pin) |ap| { + if (p.before(ap)) break :wrapped 0; + } + var row_it = p.rowIterator(.left_up, active_pin); while (row_it.next()) |next| { const row = next.rowAndCell().row; @@ -1075,7 +1361,7 @@ fn resizeCols( // Create the first node that contains our reflow. const first_rewritten_node = node: { - const page = &self.pages.first.?.data; + const page = self.pages.first.?.page(); const cap = page.capacity.adjust( .{ .cols = cols }, ) catch |err| err: { @@ -1092,8 +1378,8 @@ fn resizeCols( break :err cap; }; - const node = try self.createPage(cap); - node.data.size.rows = 1; + const node = try self.createPage(.{ .cap = cap }); + node.page().size.rows = 1; break :node node; }; @@ -1134,7 +1420,7 @@ fn resizeCols( // Once we're done reflowing a page, destroy it immediately. // This frees memory and makes it more likely in memory // constrained environments that the next reflow will work. - if (row.y == row.node.data.size.rows - 1) { + if (row.y == row.node.rows() - 1) { self.destroyNode(row.node); } } @@ -1150,7 +1436,7 @@ fn resizeCols( var node_it = self.pages.first; var total: usize = 0; while (node_it) |node| : (node_it = node.next) { - total += node.data.size.rows; + total += node.rows(); if (total >= self.rows) break; } else { for (total..self.rows) |_| _ = try self.grow(); @@ -1223,7 +1509,7 @@ const ReflowCursor = struct { total_rows: usize, fn init(node: *List.Node) ReflowCursor { - const page = &node.data; + const page = node.page(); const rows = page.rows.ptr(page.memory); return .{ .x = 0, @@ -1236,7 +1522,7 @@ const ReflowCursor = struct { .new_rows = 0, // Initially whatever size our input node is. - .total_rows = node.data.size.rows, + .total_rows = node.rows(), }; } @@ -1247,7 +1533,7 @@ const ReflowCursor = struct { row: Pin, cursor_pin: ?*Pin, ) Allocator.Error!void { - const src_page: *Page = &row.node.data; + const src_page: *Page = row.node.page(); const src_row = row.rowAndCell().row; const src_y = row.y; const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols]; @@ -1270,7 +1556,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y) continue; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1293,7 +1579,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (&p.node.data == src_page and p.y == src_y) { + if (p.node.page() == src_page and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1344,7 +1630,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x) continue; @@ -1369,7 +1655,7 @@ const ReflowCursor = struct { // since we won't process that cell in the loop. const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x + 1) continue; @@ -1853,7 +2139,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != old_page or + if (p.node.page() != old_page or p.y != old_page.size.rows - 1) continue; p.node = self.node; @@ -1954,9 +2240,9 @@ const ReflowCursor = struct { // after reinitializing our cursor on the new page. const new_rows = self.new_rows; - const node = try list.createPage(cap); + const node = try list.createPage(.{ .cap = cap }); errdefer comptime unreachable; - node.data.size.rows = 1; + node.page().size.rows = 1; list.pages.insertAfter(self.node, node); self.* = .init(node); @@ -2056,7 +2342,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { .lt => { var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); defer page.assertIntegrity(); const rows = page.rows.ptr(page.memory); for (0..page.size.rows) |i| { @@ -2153,12 +2439,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // just update our rows and we're done. This effectively // "pulls down" scrollback. // + // This traversal intentionally reads only node metadata. A + // compressed history page pulled into the active area remains + // compressed until a renderer or other content consumer goes + // through `Node.page`, which restores it transparently. + // // If we don't have enough scrollback, we add the difference, // to the active area. var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { - count += p.data.size.rows; + count += p.rows(); if (count >= rows) break; } else { assert(count < rows); @@ -2189,7 +2480,7 @@ fn resizeWithoutReflowGrowCols( chunk: PageIterator.Chunk, ) Allocator.Error!void { assert(cols > self.cols); - const page = &chunk.node.data; + const page = chunk.node.page(); // Update our col count const old_cols = self.cols; @@ -2263,7 +2554,7 @@ fn resizeWithoutReflowGrowCols( // This can fail for many reasons (can't fit styles/graphemes, etc.) so // if it fails then we give up and drop back into creating new pages. if (prev) |prev_node| prev: { - const prev_page = &prev_node.data; + const prev_page = prev_node.page(); // We only want scenarios where we have excess capacity. if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; @@ -2308,7 +2599,7 @@ fn resizeWithoutReflowGrowCols( // If we have an error, we clear the rows we just added to our prev page. const prev_copied = copied; errdefer if (prev_copied > 0) { - const prev_page = &prev.?.data; + const prev_page = prev.?.page(); const prev_size = prev_page.size.rows - prev_copied; const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows]; for (prev_rows) |*row| prev_page.clearCells( @@ -2333,8 +2624,9 @@ fn resizeWithoutReflowGrowCols( // We need to loop because our col growth may force us // to split pages. while (copied < page.size.rows) { - const new_node = try self.createPage(cap); - defer new_node.data.assertIntegrity(); + const new_node = try self.createPage(.{ .cap = cap }); + const new_page = new_node.page(); + defer new_page.assertIntegrity(); // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. @@ -2343,10 +2635,10 @@ fn resizeWithoutReflowGrowCols( // Perform the copy const y_start = copied; const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len]; - const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len]; + const dst_rows = new_page.rows.ptr(new_page.memory)[0..len]; for (dst_rows, src_rows) |*dst_row, *src_row| { - new_node.data.size.rows += 1; - if (new_node.data.cloneRowFrom( + new_page.size.rows += 1; + if (new_page.cloneRowFrom( page, dst_row, src_row, @@ -2364,7 +2656,7 @@ fn resizeWithoutReflowGrowCols( // We can actually safely handle this though by exiting // this loop early and cutting our copy short. - new_node.data.size.rows -= 1; + new_page.size.rows -= 1; break; } } @@ -2405,12 +2697,13 @@ fn trailingBlankLines( // Go through our pages backwards since we're counting trailing blanks. var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows = page.data.rows.ptr(page.data.memory)[0..len]; + while (it) |node| : (it = node.prev) { + const page = node.page(); + const len = node.rows(); + const rows = page.rows.ptr(page.memory)[0..len]; for (0..len) |i| { const rev_i = len - i - 1; - const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + const cells = rows[rev_i].cells.ptr(page.memory)[0..node.cols()]; // If the row has any text then we're done. if (pagepkg.Cell.hasTextAny(cells)) return count; @@ -2436,6 +2729,7 @@ fn trimTrailingBlankRows( max: size.CellCountInt, ) size.CellCountInt { var trimmed: size.CellCountInt = 0; + var invalidated_node: ?*List.Node = null; const bl_pin = self.getBottomRight(.screen).?; var it = bl_pin.rowIterator(.left_up, null); while (it.next()) |row_pin| { @@ -2457,11 +2751,16 @@ fn trimTrailingBlankRows( // No text, we can trim this row. Because it has // no text we can also be sure it has no styling // so we don't need to worry about memory. - row_pin.node.data.size.rows -= 1; - if (row_pin.node.data.size.rows == 0) { + if (row_pin.node.rows() > 1 and invalidated_node != row_pin.node) { + // Shrinking a retained page changes its valid row-coordinate range. + self.invalidateNodeLayout(row_pin.node); + invalidated_node = row_pin.node; + } + row_pin.node.page().size.rows -= 1; + if (row_pin.node.page().size.rows == 0) { self.erasePage(row_pin.node); } else { - row_pin.node.data.assertIntegrity(); + row_pin.node.page().assertIntegrity(); } trimmed += 1; @@ -2514,6 +2813,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { return; } + // Moving the viewport changes which historical pages are visible. Restart + // traversal so pages which leave the viewport are reconsidered after the + // renderer's idle delay. False positives from clamped scrolling are cheap. + defer { + self.page_compression.reset(); + self.page_compression.markActivity(); + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -2572,44 +2879,38 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { if (n < midpoint) { // Iterate forward from the first node. var node_it = self.pages.first; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = n; while (node_it) |node| : (node_it = node.next) { - if (rem < node.data.size.rows) { + if (rem < node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = rem, + .y = std.math.cast(size.CellCountInt, rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } else { // Iterate backwards from the last node. var node_it = self.pages.last; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - self.total_rows - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = self.total_rows - n; while (node_it) |node| : (node_it = node.prev) { - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = node.data.size.rows - rem, + .y = std.math.cast(size.CellCountInt, node.rows() - rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } @@ -2619,6 +2920,8 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| delta_row: { + const amount: usize = @abs(n); + switch (self.viewport) { // If we're at the top and we're scrolling backwards, // we don't have to do anything, because there's nowhere to go. @@ -2635,11 +2938,11 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { .pin => switch (std.math.order(n, 0)) { .eq => break :delta_row, - .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) { + .lt => switch (self.viewport_pin.upOverflow(amount)) { .offset => |new_pin| { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* -= @as(usize, @intCast(-n)); + v.* -= amount; } break :delta_row; }, @@ -2651,7 +2954,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, }, - .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) { + .gt => switch (self.viewport_pin.downOverflow(amount)) { // If we offset its a valid pin but we still have to // check if we're in the active area. .offset => |new_pin| { @@ -2660,7 +2963,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } else { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* += @intCast(n); + v.* += amount; } } break :delta_row; @@ -2678,10 +2981,10 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // Slow path: we have to calculate the new pin by moving // from our viewport. const top = self.getTopLeft(.viewport); - const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { + const p: Pin = if (n < 0) switch (top.upOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, - } else switch (top.downOverflow(@intCast(n))) { + } else switch (top.downOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, }; @@ -2719,7 +3022,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { fn scrollPrompt(self: *PageList, delta: isize) void { // If we aren't jumping any prompts then we don't need to do anything. if (delta == 0) return; - const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + const delta_start: usize = @abs(delta); var delta_rem: usize = delta_start; // We start at the row before or after our viewport depending on the @@ -2781,11 +3084,12 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { var page = self.pages.last.?; var n: usize = 0; while (true) { - const rows: [*]Row = page.data.rows.ptr(page.data.memory); - for (0..page.data.size.rows) |i| { - const rev_i = page.data.size.rows - i - 1; + const current_page = page.page(); + const rows: [*]Row = current_page.rows.ptr(current_page.memory); + for (0..page.rows()) |i| { + const rev_i = page.rows() - i - 1; const row = rows[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + const cells = row.cells.ptr(current_page.memory)[0..self.cols]; for (cells) |cell| { if (!cell.isEmpty()) break :non_empty self.rows - n; } @@ -2802,40 +3106,90 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { for (0..non_empty) |_| _ = try self.grow(); } +/// Give a live node a new generation before changing its coordinate layout in +/// place. +/// +/// This must be called when a node remains at the same address and stays in the +/// list, but a mutation changes which logical row a `(node, y)` coordinate +/// identifies or whether that coordinate is still in range. Examples include +/// rotating rows after an erase, truncating a page's row range, reinitializing +/// the sole remaining page, or shortening the source page of a split. Without +/// a new generation, a cached pointer, serial, and coordinate could still pass +/// `nodeIsValid` while referring to a different row than it originally did. +/// Screen and Terminal fast paths which manipulate Page rows directly must use +/// this because they bypass PageList's own row-mutation helpers. +/// +/// The caller must pass a node which is currently live in this PageList. Call +/// this at the point the operation commits to changing the layout and before +/// the first such change. When an operation is intended to be atomic, finish +/// its failable preparation first so a failed operation does not needlessly +/// invalidate references. One call per affected node is sufficient when an +/// operation performs several layout changes without exposing intermediate +/// references. +/// +/// This helper is only needed for in-place changes. Removing or replacing a +/// node already invalidates old references through the live-list check in +/// `nodeIsValid`, and newly allocated or reused nodes receive a fresh serial as +/// part of their initialization. Ordinary cell or style changes which preserve +/// the meaning of page coordinates do not require a new generation solely for +/// that reason. +/// +/// The only state changed here is `node.serial` and the next-generation counter +/// `page_serial`. Consequently, every previously captured pointer-plus-serial +/// pair for this node becomes invalid while new references can capture the new +/// generation. This does not advance `page_serial_epoch`: only `reset` starts a +/// new whole-list validity epoch. +/// +/// This also does not mutate the page, update tracked pins or viewport state, +/// adjust row or memory accounting, mark cells dirty, or notify incremental +/// compression. The surrounding operation remains responsible for all such +/// bookkeeping required by its layout change. +pub fn invalidateNodeLayout(self: *PageList, node: *List.Node) void { + node.serial = self.page_serial; + self.page_serial += 1; +} + /// Compact a page to use the minimum required memory for the contents /// it stores. Returns the new node pointer if compaction occurred, or null -/// if the page was already compact or compaction would not provide meaningful +/// if the page was already compact or compaction would not provide any /// savings. /// -/// The current design of PageList at the time of writing this doesn't -/// allow for smaller than `std_size` nodes so if the current node's backing -/// page is standard size or smaller, no compaction will occur. In the -/// future we should fix this up. +/// The compacted page is always an exact-size heap allocation, never +/// a pool item, since a pool item always retains a full std_size +/// buffer regardless of the page layout. Note that this means that +/// when compacting a pool-owned node, the freed pool item is returned +/// to the pool free list, so the memory savings are only fully +/// realized once the pool itself is reset or freed. /// /// If this returns OOM, the PageList is left unchanged and no dangling /// memory references exist. It is safe to ignore the error and continue using /// the uncompacted page. pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // We should never have empty rows in our pagelist anyways... assert(page.size.rows > 0); - // We never compact standard size or smaller pages because changing - // the capacity to something smaller won't save memory. - if (page.memory.len <= std_size) return null; - // Compute the minimum capacity required for this page's content const req_cap = page.exactRowCapacity(0, page.size.rows); const new_size = Page.layout(req_cap).total_size; - const old_size = page.memory.len; + + // The memory this node currently retains. A pool-owned node always + // retains a full pool item no matter its layout size. + const old_size: usize = switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; if (new_size >= old_size) return null; // Create the new smaller page - const new_node = try self.createPage(req_cap); + const new_node = try self.createPage(.{ + .cap = req_cap, + .exact_size = true, + }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); new_page.size = page.size; new_page.dirty = page.dirty; new_page.cloneFrom( @@ -2866,6 +3220,7 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; @@ -2896,7 +3251,7 @@ pub fn split( // when we modify it later it updates our p here. Copying the node // fixes this. const original_node = p.node; - const page: *Page = &original_node.data; + const page: *Page = original_node.page(); // A page that is already 1 row can't be split. In the future we can // theoretically maybe split by soft-wrapping multiple pages but that @@ -2912,18 +3267,18 @@ pub fn split( defer self.assertIntegrity(); // Create a new node with the same capacity of managed memory. - const target = try self.createPage(page.capacity); + const target = try self.createPage(.{ .cap = page.capacity }); errdefer self.destroyNode(target); // Determine how many rows we're copying const y_start = p.y; const y_end = page.size.rows; - target.data.size.rows = y_end - y_start; - assert(target.data.size.rows <= target.data.capacity.rows); + target.page().size.rows = y_end - y_start; + assert(target.rows() <= target.capacity().rows); // Copy our old data. This should NOT fail because we have the // capacity of the old page which already fits the data we requested. - target.data.cloneFrom(page, y_start, y_end) catch |err| { + target.page().cloneFrom(page, y_start, y_end) catch |err| { log.err( "error cloning rows for split err={}", .{err}, @@ -2939,9 +3294,13 @@ pub fn split( // error handling. It is possible but we haven't written it. errdefer comptime unreachable; + // Failable split work is complete; shortening the source changes its row range. + self.invalidateNodeLayout(original_node); + self.page_compression.markActivity(); + // Move any tracked pins from the copied rows for (self.tracked_pins.keys()) |tracked| { - if (&tracked.node.data != page or + if (tracked.node.page() != page or tracked.y < p.y) continue; tracked.node = target; @@ -3008,9 +3367,11 @@ pub const Scrollbar = struct { /// Return the scrollbar state for this PageList. /// -/// This may be expensive to calculate depending on where the viewport -/// is (arbitrary pins are expensive). The caller should take care to only -/// call this as needed and not too frequently. +/// This is amortized O(1): the total is maintained incrementally and +/// the viewport offset is cached. The first call after the viewport +/// moves to an arbitrary pin (e.g. scrolling to a selection) may cost +/// O(pages) to compute the offset, after which it is cached again. +/// See viewportRowOffset for more details. pub fn scrollbar(self: *PageList) Scrollbar { // If we have no scrollback, special case no scrollbar. // We need to do this because the way PageList works is that @@ -3057,9 +3418,9 @@ fn viewportRowOffset(self: *PageList) usize { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { - assert(n.data.size.rows > self.viewport_pin.y); + assert(n.rows() > self.viewport_pin.y); offset -= self.viewport_pin.y; break :offset self.total_rows - offset; } @@ -3134,11 +3495,15 @@ pub fn maxSize(self: *const PageList) usize { pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); + // Growing can move a complete page behind the active boundary. + self.page_compression.markActivity(); + const last = self.pages.last.?; - if (last.data.capacity.rows > last.data.size.rows) { + if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. - last.data.size.rows += 1; - last.data.assertIntegrity(); + const page = last.page(); + page.size.rows += 1; + page.assertIntegrity(); // Increase our total rows by one self.total_rows += 1; @@ -3168,7 +3533,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { assert(first != last); // Decrease our total row count from the pruned page - self.total_rows -= first.data.size.rows; + self.total_rows -= first.rows(); // If our total row count is now less than our required // rows then we can't prune. The "+ 1" is because we'll add one @@ -3176,7 +3541,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.total_rows + 1 < self.rows) { self.pages.prepend(first); assert(self.pages.first == first); - self.total_rows += first.data.size.rows; + self.total_rows += first.rows(); break :prune; } @@ -3185,14 +3550,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.viewport_pin_row_offset) |*v| { // If our offset is less than the number of rows in the // pruned page, then we are now at the top. - if (v.* < first.data.size.rows) { + if (v.* < first.rows()) { self.viewport = .top; break :viewport; } // Otherwise, our viewport pin is below what we pruned // so we just decrement our offset. - v.* -= first.data.size.rows; + v.* -= first.rows(); } } @@ -3208,48 +3573,56 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { } self.viewport_pin.garbage = false; - // Non-standard pages can't be reused, just destroy them. - if (first.data.memory.len > std_size) { - self.destroyNode(first); - break :prune; + switch (first.owned) { + // Pool-owned pages are reused below. + .pool => {}, + + // Heap-owned pages can't be reused because they may be + // any size (larger or smaller than a standard page), so + // just destroy them. + .heap => { + self.destroyNode(first); + break :prune; + }, } // Reset our memory - const buf = first.data.memory; + const buf = first.restore(.discard).memory; @memset(buf, 0); assert(buf.len <= std_size); // Initialize our new page and reinsert it as the last - first.data = .initBuf(.init(buf), Page.layout(cap)); - first.data.size.rows = 1; + first.data = .{ .resident = .initBuf(.init(buf), Page.layout(cap)) }; + const page = first.page(); + page.size.rows = 1; self.pages.insertAfter(last, first); self.total_rows += 1; - // We also need to reset the serial number. Since this is the only - // place we ever reuse a serial number, we also can safely set - // page_serial_min to be one more than the old serial because we - // only ever prune the oldest pages. - self.page_serial_min = first.serial + 1; + // Reusing the node gives it a fresh generation. Do not begin a new + // page_serial_epoch here: generations are not monotonic in list order, + // so older live successors may have lower generations. The epoch only + // advances when reset invalidates the entire list. first.serial = self.page_serial; self.page_serial += 1; // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. - first.data.assertIntegrity(); + page.assertIntegrity(); return first; } // We need to allocate a new memory buffer. - const next_node = try self.createPage(cap); + const next_node = try self.createPage(.{ .cap = cap }); // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_node); - next_node.data.size.rows = 1; + const page = next_node.page(); + page.size.rows = 1; // We should never be more than our max size here because we've // verified the case above. - next_node.data.assertIntegrity(); + page.assertIntegrity(); // Record the increased row count self.total_rows += 1; @@ -3296,7 +3669,7 @@ pub fn increaseCapacity( adjustment: ?IncreaseCapacity, ) IncreaseCapacityError!*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // Apply our adjustment var cap = page.capacity; @@ -3306,21 +3679,36 @@ pub fn increaseCapacity( const Int = @FieldType(Capacity, field_name); const old = @field(cap, field_name); - // We use checked math to prevent overflow. If there is an - // overflow it means we're out of space in this dimension, - // since pages can take up to their maxInt capacity in any - // category. - const new = std.math.mul( - Int, - old, - 2, - ) catch |err| overflow: { - comptime assert(@TypeOf(err) == error{Overflow}); - // Our final doubling would overflow since maxInt is - // 2^N - 1 for an unsignged int of N bits. So, if we overflow - // and we haven't used all the bits, use all the bits. - if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); - return error.OutOfSpace; + const new: Int = new: { + // A dimension can be zero for pages with exact + // capacities (see compact). Doubling zero stays zero, + // which would break our guarantee that we always + // increase by at least one unit and turn caller retry + // loops into infinite loops. Jump straight to the + // standard default instead: it is what all standard + // pages start with, so retrying callers are guaranteed + // enough room for their pending allocation. + if (old == 0) { + const default: Capacity = .{ .cols = 0, .rows = 0 }; + break :new @field(default, field_name); + } + + // We use checked math to prevent overflow. If there is + // an overflow it means we're out of space in this + // dimension, since pages can take up to their maxInt + // capacity in any category. + break :new std.math.mul( + Int, + old, + 2, + ) catch |err| overflow: { + comptime assert(@TypeOf(err) == error{Overflow}); + // Our final doubling would overflow since maxInt is + // 2^N - 1 for an unsignged int of N bits. So, if we overflow + // and we haven't used all the bits, use all the bits. + if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); + return error.OutOfSpace; + }; }; @field(cap, field_name) = new; @@ -3337,9 +3725,9 @@ pub fn increaseCapacity( log.info("adjusting page capacity={}", .{cap}); // Create our new page and clone the old page into it. - const new_node = try self.createPage(cap); + const new_node = try self.createPage(.{ .cap = cap }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); assert(new_page.capacity.rows >= page.capacity.rows); assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; @@ -3375,21 +3763,35 @@ pub fn increaseCapacity( self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; } +/// Options for createPage and createPageExt. +const CreatePage = struct { + /// The capacity to allocate the page with. + cap: Capacity, + + /// Force the page backing memory to be an exact-size heap + /// allocation even if it would fit within a standard-size pool + /// item. This is used when compacting pages to their minimum + /// size, since a pool item always retains a full std_size buffer + /// regardless of the page layout. + exact_size: bool = false, +}; + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. inline fn createPage( self: *PageList, - cap: Capacity, + opts: CreatePage, ) Allocator.Error!*List.Node { - // log.debug("create page cap={}", .{cap}); + // log.debug("create page cap={}", .{opts.cap}); return try createPageExt( &self.pool, - cap, + opts, &self.page_serial, &self.page_size, ); @@ -3397,15 +3799,15 @@ inline fn createPage( inline fn createPageExt( pool: *MemoryPool, - cap: Capacity, + opts: CreatePage, serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); errdefer pool.nodes.destroy(page); - const layout = Page.layout(cap); - const pooled = layout.total_size <= std_size; + const layout = Page.layout(opts.cap); + const pooled = !opts.exact_size and layout.total_size <= std_size; const page_alloc = pool.pages.arena.child_allocator; // It would be better to encode this into the Zig error handling @@ -3416,14 +3818,15 @@ inline fn createPageExt( // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. - const page_buf = if (pooled) - try pool.pages.create() - else - try page_alloc.alignedAlloc( - u8, - .fromByteUnits(std.heap.page_size_min), - layout.total_size, - ); + const page_buf = if (pooled) buf: { + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; + } else try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + layout.total_size, + ); errdefer if (pooled) pool.pages.destroy(page_buf) else @@ -3431,14 +3834,23 @@ inline fn createPageExt( // In runtime safety modes, allocators fill with 0xAA. On freestanding // (WASM), the WasmAllocator reuses freed slots without zeroing. + // + // Otherwise, we rely on pool item buffers being zeroed: fresh items + // come from the OS page allocator (zeroed pages) and destroyNodeExt + // zeroes buffers before returning them to the pool. The one + // exception is the first pointer-size bytes, which hold the pool's + // free list node while a buffer is in the free list; initBuf below + // always overwrites those since the page rows start at offset 0 + // (comptime-asserted in Page). if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding) @memset(page_buf, 0); page.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - page.data.size.rows = 0; + page.page().size.rows = 0; serial.* += 1; if (total_size) |v| { @@ -3451,6 +3863,410 @@ inline fn createPageExt( return page; } +/// Temporary output memory used while creating a compressed page. +/// +/// Standard-sized output borrows a page-pool item so repeated compression can +/// reuse the same virtual mapping. Oversized pages use a temporary allocation +/// from the page allocator and release it immediately after compression. +const CompressionScratch = union(enum) { + pooled: *align(std.heap.page_size_min) [std_size]u8, + allocated: []align(std.heap.page_size_min) u8, + + fn init( + pool: *MemoryPool, + required: usize, + raw_len: usize, + ) Allocator.Error!CompressionScratch { + assert(required <= raw_len); + + if (required <= std_size) { + const memory = try pool.pages.create(); + terminal_mem.recommit(memory); + return .{ .pooled = memory }; + } + + const page_alloc = pool.pages.arena.child_allocator; + return .{ .allocated = try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + raw_len, + ) }; + } + + fn bytes(self: *CompressionScratch) []u8 { + return switch (self.*) { + .pooled => |memory| memory, + .allocated => |memory| memory, + }; + } + + fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + switch (self.*) { + .pooled => |memory| { + _ = terminal_mem.decommit(.zero, memory, memory.len); + pool.pages.destroy(memory); + }, + .allocated => |memory| { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(memory); + }, + } + } +}; + +/// PageList-owned state for incremental compression. +/// +/// The position is stored as a page serial rather than a node pointer so it +/// remains safe when PageList operations destroy, replace, or reuse nodes +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// exact serial no longer exists in the cold prefix, the next step also +/// restarts at the first page. +/// +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { + flags: packed struct { + /// Set after any page is compressed during the current traversal. + /// We always run incremental compression until we get a fully + /// no-compression pass (the verification pass). This lets us + /// recompress decompressed pages due to search, scrolling, etc. + did_compress: bool = false, + + /// Set after a traversal first reaches the active boundary. When this + /// verification traversal reaches the boundary, `did_compress` + /// determines whether to restart once more or finish the pass. + verifying: bool = false, + } = .{}, + + /// Changes whenever PageList activity may affect compression work. + /// Callers use this value (via Terminal.compressionActivity) to + /// determine whether to recompress. + /// + /// The directionality of this doesn't matter. We overflow and + /// wrap when maxxed. The comparison of this value to your saved + /// value is all that matters. There is a possible edge case where you + /// don't compress, a full wraparound happens, and you get the same value, + /// but its so unlikely. + /// + /// This is 48-bits so that 16-bits can be reserved for Terminal to + /// add extra state. + activity_serial: u48 = 0, + + /// Serial of the last page inspected by the traversal. This is + /// intentionally an implementation detail and is reset by PageList + /// operations which restart traversal progress. + last_serial: ?u64 = null, + + /// First node serial which had not been allocated at the prior step. A + /// node at or above this value before the saved marker requires a restart. + next_serial: u64 = 0, + + /// Record activity without disturbing valid traversal progress. + fn markActivity(self: *IncrementalCompressionState) void { + self.activity_serial +%= 1; + } + + /// Discard traversal progress without changing the activity token. + fn reset(self: *IncrementalCompressionState) void { + const activity_serial: u48 = self.activity_serial; + self.* = .{ .activity_serial = activity_serial }; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. + pending, + + /// A complete verification pass compressed zero pages. + complete, +}; + +/// Iterate complete historical pages which do not intersect the viewport. +/// +/// All boundaries come from PageList pins and Page metadata. Advancing this +/// iterator never restores a compressed page or reads its backing memory. +const CompressionIterator = struct { + current: *List.Node, + active: *List.Node, + viewport_first: *List.Node, + viewport_last: *List.Node, + + fn init(self: *const PageList) CompressionIterator { + return .{ + .current = self.pages.first.?, + .active = self.getTopLeft(.active).node, + .viewport_first = self.getTopLeft(.viewport).node, + .viewport_last = self.getBottomRight(.viewport).?.node, + }; + } + + fn next(self: *CompressionIterator) ?*List.Node { + while (self.current != self.active) { + // The viewport is a contiguous node range. Once traversal reaches + // its first node, advance through the complete visible range and + // resume at the next offscreen page. + if (self.current == self.viewport_first) { + while (self.current != self.active and + self.current != self.viewport_last) + { + self.current = self.current.next.?; + } + + if (self.current == self.active) return null; + self.current = self.current.next.?; + continue; + } + + const node = self.current; + self.current = node.next.?; + return node; + } + + return null; + } + + fn done(self: *const CompressionIterator) bool { + return self.current == self.active; + } +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Compress eligible nodes, saving a significant amount of memory. +/// +/// Eligible nodes are complete pages before the active boundary which do not +/// intersect the viewport. The boundary page is excluded because it may +/// contain both scrollback and active rows; visible pages remain resident for +/// immediate redraw and scrolling. +/// +/// Compression requires a system that supports reclaiming physical memory for +/// virtual allocations while retaining their address ranges. +/// +/// Compression is SLOW (relatively), so incremental compression during idle +/// periods is recommended. Incremental mode performs one bounded step and its +/// result specifies whether to continue immediately. Drain mode performs +/// incremental steps until the pass and its verification pass finish. Full +/// mode visits every currently eligible node once without using incremental +/// state. +/// +/// PageList tracks mutations which require a later incremental pass in its +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. +pub fn compress( + self: *PageList, + mode: enum { incremental, drain, full }, +) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .drain => while (true) switch (self.compressIncremental()) { + .pending => continue, + .complete => break .complete, + .unsupported => break .unsupported, + }, + .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + const state = &self.page_compression; + + // If we can't reclaim virtual memory, compression is unsupported. + if (!terminal_mem.canReclaim(.strict)) { + state.reset(); + return .unsupported; + } + + // Find the node following the exact continuation marker within the cold + // prefix. A missing marker means the list changed between steps, so begin + // again at the current first page. This lookup does not touch page memory. + var it: CompressionIterator = .init(self); + if (state.last_serial) |last_serial| continuation: { + while (it.next()) |node| { + // A newly allocated or replacement node appeared before the + // marker. Restart immediately so that node cannot be skipped. + if (node.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (node.serial != last_serial) continue; + + // Match! The iterator already points to the next offscreen node. + break :continuation; + } + + // Not found or otherwise invalid. Reset + state.last_serial = null; + it = .init(self); + } + + // Keep track of our next_serial + state.next_serial = self.page_serial; + + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. + var inspected_pages: usize = 0; + while (inspected_pages < incremental_compression_max_inspected) { + const node = it.next() orelse break; + + state.last_serial = node.serial; + inspected_pages += 1; + + // If this page is already compressed, ignore it. + if (node.isCompressed()) continue; + + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(node)) state.flags.did_compress = true; + break; + } + + // If we didn't reach our active node, then we still have work to do. + if (!it.done()) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.flags.verifying or state.flags.did_compress) { + const activity_serial: u48 = state.activity_serial; + state.* = .{ + .flags = .{ .verifying = true }, + .activity_serial = activity_serial, + }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; +} + +/// Compress every fully historical resident page which is currently cold. +fn compressFull(self: *PageList) void { + var it: CompressionIterator = .init(self); + while (it.next()) |node| { + + // Don't restore an already-compressed page just to recompress it. + if (node.isCompressed()) continue; + + // Failure leaves this node resident and unchanged. + _ = self.compressPage(node); + } +} + +/// Attempt to compress one resident page while retaining its raw mapping. +/// +/// Compression is opportunistic: every failure leaves the page resident and +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. +fn compressPage(self: *PageList, node: *List.Node) bool { + // Recompression requires first restoring the raw page and is a policy + // decision, so this primitive only accepts resident nodes. + if (node.isCompressed()) return false; + + const page = node.page(); + + // The scratch size is capped just below the representation's break-even + // point. Codec limits and pages too small to cover the compressed-state + // overhead simply make this page ineligible for compression. + const required = compression.Page.requiredScratch(page.memory.len) catch |err| + switch (err) { + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + if (required == 0) return false; + + // Build the compressed candidate without changing the node. This scope is + // intentional: its defer releases the borrowed or temporary scratch before + // we attempt to discard the source mapping below. Only the candidate's + // exact-sized encoded allocation survives the scope. + const candidate = candidate: { + var scratch = CompressionScratch.init( + &self.pool, + required, + page.memory.len, + ) catch |err| switch (err) { + error.OutOfMemory => return false, + }; + defer scratch.deinit(&self.pool); + + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( + self.pool.alloc, + page, + scratch.bytes()[0..required], + &table, + ) catch |err| switch (err) { + error.OutOfMemory, + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + }; + + // Null means compression crossed the break-even point. The node and its + // resident mapping are still untouched in this case. + var compressed = candidate orelse return false; + + // Strict decommit is the final fallible step. It either discards the whole + // raw mapping or leaves it untouched, so failure can safely free the + // candidate and preserve the resident node exactly as it was. + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( + .strict, + compressed.page.memory, + compressed.page.memory.len, + )) { + compressed.deinit(); + return false; + } + + // Publish the new state only after both the encoded allocation and the + // retained-mapping decommit have succeeded. + node.data = .{ .compressed = compressed }; + return true; +} + /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. @@ -3467,18 +4283,33 @@ fn destroyNodeExt( node: *List.Node, total_size: ?*usize, ) void { - const page: *Page = &node.data; + const page = node.restore(.discard); + + // Update our accounting for page size. This must mirror what was + // added at creation time: a pool-owned page always accounts for a + // full pool item even if its layout is smaller, while a heap-owned + // page accounts for its exact memory length. + if (total_size) |v| v.* -= switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; - // Update our accounting for page size - if (total_size) |v| v.* -= page.memory.len; + switch (node.owned) { + .pool => { + assert(page.memory.len <= std_size); - if (page.memory.len <= std_size) { - // Reset the memory to zero so it can be reused - @memset(page.memory, 0); - pool.pages.destroy(@ptrCast(page.memory.ptr)); - } else { - const page_alloc = pool.pages.arena.child_allocator; - page_alloc.free(page.memory); + // Reset the memory to zero (and decommit it, where + // supported) so it can be reused. + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + _ = terminal_mem.decommit(.zero, item, page.memory.len); + pool.pages.destroy(item); + }, + + .heap => { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(page.memory); + }, } pool.nodes.destroy(node); @@ -3499,12 +4330,20 @@ pub fn eraseRow( const pn = self.pin(pt).?; var node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // In order to move the following rows up we rotate the rows array by 1. // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which // works perfectly to move all of our elements where they belong. - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating rows changes which logical row cached coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // We adjust the tracked pins in this page, moving up any that were below // the removed row. @@ -3522,12 +4361,13 @@ pub fn eraseRow( // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); // We take the top row of the page and clone it in to the bottom // row of the previous page, which gets rid of the top row that was @@ -3544,19 +4384,22 @@ pub fn eraseRow( // 5 5 5 | 6 // 6 6 6 | 7 // 7 7 7 <' 4 - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating this page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -3566,7 +4409,7 @@ pub fn eraseRow( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3574,7 +4417,7 @@ pub fn eraseRow( } // Clear the final row which was rotated from the top of the page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// A variant of eraseRow that shifts only a bounded number of following @@ -3598,20 +4441,28 @@ pub fn eraseRowBounded( const pn = self.pin(pt).?; var node: *List.Node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // If the row limit is less than the remaining rows before the end of the // page, then we clear the row, rotate it to the end of the boundary limit // and update our pins. - if (node.data.size.rows - pn.y > limit) { - node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); + if (node.rows() - pn.y > limit) { + // Rotating this bounded region changes its cached row coordinates. + self.invalidateNodeLayout(node); + page.clearCells(&rows[pn.y], 0, node.cols()); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -3646,18 +4497,20 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating this suffix changes which logical row its coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // Mark the whole page as dirty. // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent // pages. - var shifted: usize = node.data.size.rows - pn.y; + var shifted: usize = node.rows() - pn.y; // Update tracked pins. { @@ -3686,15 +4539,17 @@ pub fn eraseRowBounded( } while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; // We check to see if this page contains enough rows to satisfy the @@ -3703,15 +4558,17 @@ pub fn eraseRowBounded( // // The logic here is very similar to the one before the loop. const shifted_limit = limit - shifted; - if (node.data.size.rows > shifted_limit) { - node.data.clearCells(&rows[0], 0, node.data.size.cols); + if (node.rows() > shifted_limit) { + // Rotating this bounded prefix changes its cached row coordinates. + self.invalidateNodeLayout(node); + page.clearCells(&rows[0], 0, node.cols()); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -3730,7 +4587,7 @@ pub fn eraseRowBounded( if (p.node != node or p.y > shifted_limit) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3739,13 +4596,15 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating the whole page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Account for the rows shifted in this node. - shifted += node.data.size.rows; + shifted += node.rows(); // See the other places we do something similar in this function // for a detailed explanation. @@ -3763,7 +4622,7 @@ pub fn eraseRowBounded( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3772,7 +4631,7 @@ pub fn eraseRowBounded( // We reached the end of the page list before the limit, so we clear // the final row since it was rotated down from the top of this page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// Erase all history rows, optionally up to a bottom-left bound. @@ -3800,9 +4659,8 @@ pub fn eraseActive( /// and the page becomes underutilized (size < capacity). /// /// Callers must ensure that the erased range only removes pages from -/// the front or back of the linked list, never the middle. Middle-page -/// erasure would create serial gaps that page_serial_min cannot -/// represent, leaving dangling references in consumers such as search. +/// the front or back of the linked list, never the middle. The pin and row +/// accounting in this operation is only defined for those boundary ranges. /// Use the public eraseHistory/eraseActive wrappers which enforce this. fn eraseRows( self: *PageList, @@ -3810,6 +4668,7 @@ fn eraseRows( bl_pt: ?point.Point, ) void { defer self.assertIntegrity(); + self.page_compression.markActivity(); // The count of rows that was erased. var erased: usize = 0; @@ -3827,27 +4686,33 @@ fn eraseRows( // page so to handle this we reinit this page, set it to zero // size which will let us grow our active area back. if (chunk.node.next == null and chunk.node.prev == null) { - const page = &chunk.node.data; + // Reinitializing the sole page invalidates every coordinate in it. + self.invalidateNodeLayout(chunk.node); + const page = chunk.node.page(); erased += page.size.rows; page.reinit(); page.size.rows = 0; break; } - erased += chunk.node.data.size.rows; + erased += chunk.node.rows(); self.erasePage(chunk.node); continue; } + // Moving the retained suffix changes every captured row coordinate. + self.invalidateNodeLayout(chunk.node); + // We are modifying our chunk so make sure it is in a good state. - defer chunk.node.data.assertIntegrity(); + const page = chunk.node.page(); + defer page.assertIntegrity(); // The chunk is not a full page so we need to move the rows. // This is a cheap operation because we're just moving cell offsets, // not the actual cell contents. assert(chunk.start == 0); - const rows = chunk.node.data.rows.ptr(chunk.node.data.memory); - const scroll_amount = chunk.node.data.size.rows - chunk.end; + const rows = page.rows.ptr(page.memory); + const scroll_amount = chunk.node.rows() - chunk.end; for (0..scroll_amount) |i| { const src: *Row = &rows[i + chunk.end]; const dst: *Row = &rows[i]; @@ -3861,12 +4726,12 @@ fn eraseRows( // Clear our remaining cells that we didn't shift or swapped // in case we grow back into them. - for (scroll_amount..chunk.node.data.size.rows) |i| { + for (scroll_amount..chunk.node.rows()) |i| { const row: *Row = &rows[i]; - chunk.node.data.clearCells( + page.clearCells( row, 0, - chunk.node.data.size.cols, + chunk.node.cols(), ); } @@ -3884,7 +4749,7 @@ fn eraseRows( } // Our new size is the amount we scrolled - chunk.node.data.size.rows = @intCast(scroll_amount); + page.size.rows = @intCast(scroll_amount); erased += chunk.end; } @@ -3920,17 +4785,10 @@ fn erasePage(self: *PageList, node: *List.Node) void { // Must not be the final page. assert(node.next != null or node.prev != null); - // We only support erasing from the front or back, never the middle. - // Middle erasure would create serial gaps that page_serial_min can't - // represent. If this ever needs to change, we'll need a more - // sophisticated invalidation mechanism. + // We only support erasing from the front or back, never the middle. The + // public erase operations maintain this contract by construction. assert(node.prev == null or node.next == null); - // If we're erasing the first page, update page_serial_min so that - // any external references holding this page's serial will know it - // has been invalidated. - if (node.prev == null) self.page_serial_min = node.next.?.serial; - // Update any tracked pins to move to the previous or next page. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -3962,6 +4820,9 @@ pub fn pin(self: *const PageList, pt: point.Point) ?Pin { // Grab the top left and move to the point. var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; + // Incomplete reflow can leave a page narrower than the desired width. + // Never manufacture an out-of-bounds pin for that page. + if (x >= p.node.cols()) return null; p.x = x; return p; } @@ -4014,8 +4875,28 @@ pub fn pinIsValid(self: *const PageList, p: Pin) bool { var it = self.pages.first; while (it) |node| : (it = node.next) { if (node != p.node) continue; - return p.y < node.data.size.rows and - p.x < node.data.size.cols; + return p.y < node.rows() and + p.x < node.cols(); + } + + return false; +} + +/// Returns whether a node pointer and serial still identify the same page in +/// this list. The node pointer is only compared and is safe even if the node +/// has been destroyed or reused since the serial was captured. +pub fn nodeIsValid( + self: *const PageList, + target: *List.Node, + serial: u64, +) bool { + // Reset invalidates the whole prior epoch, so reject those generations + // without scanning the live list. + if (serial < self.page_serial_epoch) return false; + + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node == target) return node.serial == serial; } return false; @@ -4069,15 +4950,23 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point if (tl.y > p.y) return null; coord.y = p.y - tl.y; } else { - coord.y += tl.node.data.size.rows - tl.y; + coord.y = std.math.add( + u32, + coord.y, + tl.node.rows() - tl.y, + ) catch return null; var node_ = tl.node.next; while (node_) |node| : (node_ = node.next) { if (node == p.node) { - coord.y += p.y; + coord.y = std.math.add(u32, coord.y, p.y) catch return null; break; } - coord.y += node.data.size.rows; + coord.y = std.math.add( + u32, + coord.y, + node.rows(), + ) catch return null; } else { // We never saw our node, meaning we're outside the range. return null; @@ -4099,7 +4988,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point /// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { const pt_pin = self.pin(pt) orelse return null; - const rac = pt_pin.node.data.getRowAndCell(pt_pin.x, pt_pin.y); + const rac = pt_pin.node.page().getRowAndCell(pt_pin.x, pt_pin.y); return .{ .node = pt_pin.node, .row = rac.row, @@ -4142,13 +5031,13 @@ pub fn diagram( var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| : (page_index += 1) { - cols = chunk.node.data.size.cols; + cols = chunk.node.cols(); // Whether we've just skipped some number of rows and drawn // an ellipsis row (this is reset when a row is not skipped). var skipped = false; - for (0..chunk.node.data.size.rows) |y| { + for (0..chunk.node.rows()) |y| { // Active header if (!active and chunk.node == active_pin.node and @@ -4173,8 +5062,8 @@ pub fn diagram( // Row contents { - const row = chunk.node.data.getRow(y); - const cells = chunk.node.data.getCells(row)[0..cols]; + const row = chunk.node.page().getRow(y); + const cells = chunk.node.page().getCells(row)[0..cols]; var row_has_content = false; @@ -4223,7 +5112,7 @@ pub fn diagram( } try writer.print("{u}", .{cell.codepoint()}); if (cell.hasGrapheme()) { - const grapheme = chunk.node.data.lookupGrapheme(cell).?; + const grapheme = chunk.node.page().lookupGrapheme(cell).?; for (grapheme) |cp| { try writer.print("{u}", .{cp}); } @@ -4351,7 +5240,7 @@ pub fn highlightSemanticContent( // two prompts here. if (it.next()) |next| next: { var prev = next.up(1) orelse break :next; - prev.x = prev.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end prev; } @@ -4642,7 +5531,7 @@ pub const CellIterator = struct { switch (self.row_it.page_it.direction) { .right_down => { - if (cell.x + 1 < cell.node.data.size.cols) { + if (cell.x + 1 < cell.node.cols()) { // We still have cells in this row, increase x. var copy = cell; copy.x += 1; @@ -4663,7 +5552,7 @@ pub const CellIterator = struct { // We need to move to the previous row and last col if (self.row_it.next()) |next_cell| { var copy = next_cell; - copy.x = next_cell.node.data.size.cols - 1; + copy.x = next_cell.node.cols() - 1; self.cell = copy; } else { self.cell = null; @@ -4796,24 +5685,21 @@ pub const PageIterator = struct { break :none .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; }, .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.node.data.size.rows - row.y, limit.*); - if (len > limit.*) { - self.row = row.down(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = row.node.rows() - row.y; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.down(len) else null; break :count .{ .node = row.node, .start = row.y, - .end = row.y + len, + .end = @intCast(@as(usize, row.y) + len), }; }, @@ -4829,7 +5715,7 @@ pub const PageIterator = struct { break :row .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; } @@ -4858,7 +5744,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4871,18 +5757,15 @@ pub const PageIterator = struct { .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.y, limit.*); - if (len > limit.*) { - self.row = row.up(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = @as(usize, row.y) + 1; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.up(len) else null; break :count .{ .node = row.node, - .start = row.y - len, - .end = row.y - 1, + .start = @intCast(available - len), + .end = @intCast(available), }; }, @@ -4894,7 +5777,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4928,13 +5811,14 @@ pub const PageIterator = struct { end: size.CellCountInt, pub fn rows(self: Chunk) []Row { - const rows_ptr = self.node.data.rows.ptr(self.node.data.memory); + const page = self.node.page(); + const rows_ptr = page.rows.ptr(page.memory); return rows_ptr[self.start..self.end]; } /// Returns true if this chunk represents every row in the page. pub fn fullPage(self: Chunk) bool { - return self.start == 0 and self.end == self.node.data.size.rows; + return self.start == 0 and self.end == self.node.rows(); } /// Returns true if this chunk overlaps with the given other chunk @@ -5006,12 +5890,12 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { var rem = self.rows; var it = self.pages.last; while (it) |node| : (it = node.prev) { - if (rem <= node.data.size.rows) break :active .{ + if (rem <= node.rows()) break :active .{ .node = node, - .y = node.data.size.rows - rem, + .y = node.rows() - rem, }; - rem -= node.data.size.rows; + rem -= node.rows(); } unreachable; // assertion: we always have enough rows for active @@ -5028,22 +5912,22 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { const node = self.pages.last.?; break :last .{ .node = node, - .y = node.data.size.rows - 1, - .x = node.data.size.cols - 1, + .y = node.rows() - 1, + .x = node.cols() - 1, }; }, .viewport => viewport: { var br = self.getTopLeft(.viewport); br = br.down(self.rows - 1).?; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :viewport br; }, .history => active: { var br = self.getTopLeft(.active); br = br.up(1) orelse return null; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :active br; }, }; @@ -5058,7 +5942,7 @@ fn totalRows(self: *const PageList) usize { var rows: usize = 0; var node_ = self.pages.first; while (node_) |node| { - rows += node.data.size.rows; + rows += node.rows(); node_ = node.next; } @@ -5078,6 +5962,88 @@ pub fn totalPages(self: *const PageList) usize { return pages; } +/// Snapshot of the storage used by page nodes in this list. +/// +/// The raw byte counts describe page backing mappings only. They exclude +/// nodes, allocator metadata, unused preheated pool items, and the small +/// representation values stored in each node. A compressed page retains its +/// raw mapping as virtual address space, but its bytes are counted as +/// decommitted because strict reclamation succeeded before the state was +/// published. +pub const MemoryStats = struct { + /// Pages whose raw backing mappings are resident. + resident_pages: usize = 0, + + /// Pages represented by encoded storage and a decommitted raw mapping. + compressed_pages: usize = 0, + + /// Logical bytes in every raw page mapping. + raw_bytes: usize = 0, + + /// Raw mapping bytes which remain resident. + resident_raw_bytes: usize = 0, + + /// Raw mapping bytes discarded for compressed pages. + decommitted_raw_bytes: usize = 0, + + /// Raw allocation bytes which remain physically resident. + /// + /// This can exceed `resident_raw_bytes` because a pool-owned page uses + /// only part of a standard pool item. Compressing such a page decommits + /// its initialized range, but the unused tail of the item stays resident. + resident_backing_bytes: usize = 0, + + /// Exact encoded allocations retained for compressed pages. + encoded_bytes: usize = 0, + + /// Estimate resident page backing storage after compression. + pub fn estimatedResidentBytes(self: MemoryStats) usize { + return self.resident_backing_bytes + self.encoded_bytes; + } + + /// Estimate physical bytes avoided by compressed page backing storage. + pub fn estimatedSavings(self: MemoryStats) usize { + return self.decommitted_raw_bytes -| self.encoded_bytes; + } +}; + +/// Return a metadata-only snapshot of page backing storage. +/// +/// This never restores compressed pages. It is intended for diagnostics and +/// other infrequent reporting because it traverses the complete page list. +pub fn memoryStats(self: *const PageList) MemoryStats { + var result: MemoryStats = .{}; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + const raw_len = node.metadata().memory.len; + const backing_len = switch (node.owned) { + .pool => PagePool.item_size, + .heap => raw_len, + }; + assert(backing_len >= raw_len); + result.raw_bytes += raw_len; + + switch (node.data) { + .resident => { + result.resident_pages += 1; + result.resident_raw_bytes += raw_len; + result.resident_backing_bytes += backing_len; + }, + + .compressed => |compressed| { + result.compressed_pages += 1; + result.decommitted_raw_bytes += raw_len; + // Strict reclamation covers only Page.memory. A standard pool + // item can have an unused tail which remains resident. + result.resident_backing_bytes += backing_len - raw_len; + result.encoded_bytes += compressed.encoded.len; + }, + } + } + + return result; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) Allocator.Error!void { @@ -5089,8 +6055,9 @@ fn growRows(self: *PageList, n: usize) Allocator.Error!void { pub fn clearDirty(self: *PageList) void { var page = self.pages.first; while (page) |p| : (page = p.next) { - p.data.dirty = false; - for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + const current_page = p.page(); + current_page.dirty = false; + for (current_page.rows.ptr(current_page.memory)[0..p.rows()]) |*row| { row.dirty = false; } } @@ -5137,7 +6104,7 @@ pub const Pin = struct { row: *pagepkg.Row, cell: *pagepkg.Cell, } { - const rac = self.node.data.getRowAndCell(self.x, self.y); + const rac = self.node.page().getRowAndCell(self.x, self.y); return .{ .row = rac.row, .cell = rac.cell }; } @@ -5147,8 +6114,9 @@ pub const Pin = struct { /// what subset of the cells are returned. The "left/right" subsets are /// inclusive of the x coordinate of the pin. pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { - const rac = self.rowAndCell(); - const all = self.node.data.getCells(rac.row); + const page = self.node.page(); + const rac = page.getRowAndCell(self.x, self.y); + const all = page.getCells(rac.row); return switch (subset) { .all => all, .left => all[0 .. self.x + 1], @@ -5159,21 +6127,23 @@ pub const Pin = struct { /// Returns the grapheme codepoints for the given cell. These are only /// the EXTRA codepoints and not the first codepoint. pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 { - return self.node.data.lookupGrapheme(cell); + return self.node.page().lookupGrapheme(cell); } /// Returns the style for the given cell in this pin. pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style { if (cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, cell.style_id, ).*; } /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.dirty or self.rowAndCell().row.dirty; + const page = self.node.page(); + return page.dirty or page.getRowAndCell(self.x, self.y).row.dirty; } /// Mark this pin location as dirty. @@ -5354,7 +6324,7 @@ pub const Pin = struct { /// Move the pin right n columns. n must fit within the size. pub inline fn right(self: Pin, n: usize) Pin { - assert(self.x + n < self.node.data.size.cols); + assert(self.x + n < self.node.cols()); var result = self; result.x +|= std.math.cast(size.CellCountInt, n) orelse std.math.maxInt(size.CellCountInt); @@ -5371,7 +6341,7 @@ pub const Pin = struct { /// Move the pin right n columns, stopping at the end of the row. pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin { var result = self; - result.x = @min(self.x +| n, self.node.data.size.cols - 1); + result.x = @min(self.x +| n, self.node.cols() - 1); return result; } @@ -5381,25 +6351,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn leftWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = self.x; - - if (n <= remaining_in_row) return self.left(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.upOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(cols - extra_after_remaining % cols); - return result; - }, - .overflow => return null, + var result = self; + var remaining = n; + while (remaining > result.x) { + remaining -= @as(usize, result.x) + 1; + result = result.up(1) orelse return null; + // Crossing a row boundary lands on that destination row's final + // cell, whose width may differ from ours during reflow. + result.x = result.node.cols() - 1; } + + result.x -= @intCast(remaining); + return result; } /// Move the pin right n cells, wrapping to the next row as needed. @@ -5408,24 +6371,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn rightWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = cols - self.x - 1; - - if (n <= remaining_in_row) return self.right(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.downOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(extra_after_remaining % cols - 1); + var result = self; + var remaining = n; + while (true) { + const row_remaining = result.node.cols() - result.x - 1; + if (remaining <= row_remaining) { + result.x += @intCast(remaining); return result; - }, - .overflow => return null, + } + + remaining -= @as(usize, row_remaining) + 1; + result = result.down(1) orelse return null; + result.x = 0; } } @@ -5457,7 +6414,7 @@ pub const Pin = struct { }, } { // Index fits within this page - const rows = self.node.data.size.rows - (self.y + 1); + const rows = self.node.rows() - (self.y + 1); if (n <= rows) return .{ .offset = .{ .node = self.node, .y = std.math.cast(size.CellCountInt, self.y + n) orelse @@ -5472,18 +6429,18 @@ pub const Pin = struct { node = node.next orelse return .{ .overflow = .{ .end = .{ .node = node, - .y = node.data.size.rows - 1, - .x = self.x, + .y = node.rows() - 1, + .x = @min(self.x, node.cols() - 1), }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, .y = std.math.cast(size.CellCountInt, n_left - 1) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } @@ -5509,20 +6466,111 @@ pub const Pin = struct { var n_left: usize = n - self.y; while (true) { node = node.prev orelse return .{ .overflow = .{ - .end = .{ .node = node, .y = 0, .x = self.x }, + .end = .{ + .node = node, + .y = 0, + .x = @min(self.x, node.cols() - 1), + }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, - .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse + .y = std.math.cast(size.CellCountInt, node.rows() - n_left) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } }; +fn mixedWidthPinListForTest(alloc: Allocator) !PageList { + var result = try init(alloc, 2, 1, null); + errdefer result.deinit(); + + // This deliberately constructs a layout that normal PageList operations + // do not expose yet. Keep integrity checks paused through deinit so the + // fixture can exercise mixed-width traversal in isolation. + result.pauseIntegrityChecks(true); + + inline for (.{ 4, 3 }) |cols| { + const node = try result.createPage(.{ .cap = .{ + .cols = cols, + .rows = 1, + } }); + node.page().size.rows = 1; + result.pages.append(node); + result.total_rows += 1; + } + + // Desired geometry is wider than the first and last stored pages. + result.cols = 4; + return result; +} + +test "PageList Pin row movement clamps across mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = second, .x = 3 }).down(1).?, + )); + try testing.expect((Pin{ .node = first, .x = 1 }).eql( + (Pin{ .node = second, .x = 3 }).up(1).?, + )); + + switch ((Pin{ .node = second, .x = 3 }).downOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = third, .x = 2 }).eql(overflow.end), + ), + } + switch ((Pin{ .node = second, .x = 3 }).upOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = first, .x = 1 }).eql(overflow.end), + ), + } +} + +test "PageList Pin wrapping crosses mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = first, .x = 1 }).rightWrap(7).?, + )); + try testing.expect((Pin{ .node = second, .x = 0 }).eql( + (Pin{ .node = third, .x = 2 }).leftWrap(6).?, + )); + try testing.expect((Pin{ .node = first }).leftWrap(1) == null); + try testing.expect((Pin{ .node = third, .x = 2 }).rightWrap(1) == null); +} + +test "PageList Pin rejects columns beyond mixed-width page bounds" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + try testing.expect(s.pin(.{ .screen = .{ .x = 1, .y = 0 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 2, .y = 0 } }) == null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 1 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 2 } }) == null); +} + pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, @@ -5535,7 +6583,7 @@ pub const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.dirty or self.row.dirty; + return self.node.page().dirty or self.row.dirty; } /// Get the cell style. @@ -5543,8 +6591,9 @@ pub const Cell = struct { /// Not meant for non-test usage since this is inefficient. pub fn style(self: Cell) stylepkg.Style { if (self.cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, self.cell.style_id, ).*; } @@ -5556,10 +6605,10 @@ pub const Cell = struct { /// this file then consider a different approach and ask yourself very /// carefully if you really need this. pub fn screenPoint(self: Cell) point.Point { - var y: size.CellCountInt = self.row_idx; + var y: u32 = self.row_idx; var node_ = self.node; while (node_.prev) |node| { - y += node.data.size.rows; + y += node.rows(); node_ = node; } @@ -5570,6 +6619,1293 @@ pub const Cell = struct { } }; +/// Grow a test PageList until it contains at least `count` complete history +/// pages. The production cold-page boundary is intentionally reused here so +/// tests do not duplicate the row-to-page arithmetic. +fn growColdPagesForTest(self: *PageList, count: usize) !void { + while (true) { + const active_node = self.getTopLeft(.active).node; + var cold_count: usize = 0; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + cold_count += 1; + } + + if (cold_count >= count) return; + _ = try self.grow(); + } +} + +/// Fill the current tail page to capacity without allocating a successor. +/// Capturing the tail before the loop makes this stop at the allocation +/// boundary needed by bounded-pruning tests. +fn fillLastPageForTest(self: *PageList) !void { + const last = self.pages.last.?; + while (last.rows() < last.capacity().rows) _ = try self.grow(); +} + +/// Verify every live page belongs to the current validity epoch, has an +/// allocated generation below the next serial, and validates through the same +/// pointer-plus-generation lookup used by external references. +fn expectLivePageSerialsValidForTest(self: *const PageList) !void { + const testing = std.testing; + var node = self.pages.first; + while (node) |live| : (node = live.next) { + try testing.expect(live.serial >= self.page_serial_epoch); + try testing.expect(live.serial < self.page_serial); + try testing.expect(self.nodeIsValid(live, live.serial)); + } +} + +test "PageList Pin rightWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?; + const wrapped = start.rightWrap(14).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 9, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin leftWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 2 } }).?; + const wrapped = start.leftWrap(15).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin rightWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 0 } }).?; + try testing.expectEqual(null, start.rightWrap(std.math.maxInt(usize))); +} + +test "PageList Pin leftWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 2 } }).?; + try testing.expectEqual(null, start.leftWrap(std.math.maxInt(usize))); +} + +test "PageList incremental compression skips visible history" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + const initial_activity = s.page_compression.activity_serial; + try testing.expect(initial_activity > 0); + + s.scroll(.top); + const top_activity = s.page_compression.activity_serial; + try testing.expect(top_activity != initial_activity); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.drain), + ); + + const first = s.pages.first.?; + const second = first.next.?; + try testing.expectEqual(first, s.getTopLeft(.viewport).node); + try testing.expectEqual(first, s.getBottomRight(.viewport).?.node); + try testing.expect(!first.isCompressed()); + + var eligible: CompressionIterator = .init(&s); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); + + // Move the viewport to the start of the second page. Rendering it restores + // that page, while the first page which just left view becomes eligible. + s.scroll(.{ .row = first.rows() }); + try testing.expectEqual(second, s.getTopLeft(.viewport).node); + _ = second.page(); + try testing.expect(!second.isCompressed()); + _ = s.compress(.drain); + try testing.expect(first.isCompressed()); + try testing.expect(!second.isCompressed()); + + // Returning to the active area makes every complete historical page + // eligible again, including the page which was just visible. + s.scroll(.active); + _ = s.compress(.drain); + try testing.expect(second.isCompressed()); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ + .did_compress = true, + .verifying = true, + }, + .activity_serial = 42, + .last_serial = 42, + .next_serial = 43, + }; + + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + // Every scroll restarts traversal, even if clamping leaves the viewport in + // the same place. Missing an eligible page is worse than a no-op pass. + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.scroll(.active); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.reset(); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); +} + +test "PageList replacements preserve compression continuation and mark activity" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 42, + .last_serial = 7, + .next_serial = 8, + }; + const expected: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 43, + .last_serial = 7, + .next_serial = 8, + }; + + s.page_compression = state; + const replacement = try s.increaseCapacity(s.pages.first.?, null); + try testing.expectEqual(expected, s.page_compression); + + s.page_compression = state; + _ = (try s.compact(replacement)).?; + try testing.expectEqual(expected, s.page_compression); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.flags.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // The unsuccessful first page does not stall the pass. The next step + // continues at the following serial and compresses that page. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // next request therefore rejects the exact encoded allocation while the + // source page and pass remain valid. + failing.fail_index = failing.alloc_index; + const failed = s.compress(.incremental); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // Allow allocations again. The pass must continue with the following page + // rather than retrying the failed candidate. + failing.fail_index = std.math.maxInt(usize); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!s.pages.first.?.isCompressed()); + try tw.end(.reset); + + // The failed candidate remains resident and the pass continues at the + // next serial once reclamation is available again. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // The exact continuation serial disappeared with the old node. The pass + // restarts at the first page and considers the oversized replacement. + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + // Reset replaces every page and clears the PageList-owned traversal. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + try testing.expectEqual(first, s.getTopLeft(.active).node); + + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. + _ = first.page(); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); + + // Shrinking the active area makes the page fully historical again. The + // resize reset the PageList-owned cursor, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // Make the remaining old page fully historical. The continuation serial + // disappeared when its node was recycled, so the pass safely restarts. + try s.growColdPagesForTest(1); + _ = s.compress(.incremental); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList bounded pruning after partial erase preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const old_rows = first.rows(); + + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expectEqual(first, s.pages.first.?); + try testing.expectEqual(old_rows - 1, first.rows()); + try testing.expect(!s.nodeIsValid(first, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList partial erase restarts compression before continuation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + + const first = s.pages.first.?; + try testing.expect(first.isCompressed()); + + var marker = first; + for (1..incremental_compression_max_inspected) |_| marker = marker.next.?; + s.page_compression = .{ + .flags = .{ .verifying = true }, + .last_serial = marker.serial, + .next_serial = s.page_serial, + }; + + const activity = s.page_compression.activity_serial; + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expect(!first.isCompressed()); + try testing.expect(activity != s.page_compression.activity_serial); + + // The changed generation is before the saved marker, so continuation must + // restart and recompress it instead of reporting verification complete. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList bounded pruning after split invalidation preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const activity = s.page_compression.activity_serial; + + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + try testing.expect(!s.nodeIsValid(first, old_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList repeated bounded pruning after split preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + const epoch = s.page_serial_epoch; + while (s.totalPages() < 3) _ = try s.grow(); + const first = s.pages.first.?; + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + // The split target has a fresh serial but precedes older successor pages. + // Prune both the old source and then that target while verifying ordinary + // list mutation does not advance the whole-list validity epoch. + for (0..2) |_| { + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + _ = try s.grow(); + + // Ordinary pruning invalidates one generation at a time through live + // list validation; only reset may begin a new whole-list epoch. + try testing.expectEqual(epoch, s.page_serial_epoch); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList bounded pruning after front replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const old = s.pages.first.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList bounded pruning after middle replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 3) _ = try s.grow(); + const old = s.pages.first.?.next.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + // Prune the original first page and then the fresh middle replacement. + for (0..2) |_| { + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + _ = s.compress(.incremental); + _ = s.compress(.incremental); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compress(.incremental); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + // Allocate a new page at the active tail between steps. It is after the + // continuation marker and must not restart progress through cold history. + const next_serial = s.page_serial; + while (s.page_serial == next_serial) _ = try s.grow(); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.flags.verifying); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + _ = s.compress(.full); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + +test "PageList does not compress the mixed history and active page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // One additional row creates history, but the history and all active rows + // still share the first page. The active boundary therefore has a + // historical prefix and must remain resident as one indivisible mapping. + _ = try s.grow(); + const active = s.getTopLeft(.active); + try testing.expectEqual(s.pages.first.?, active.node); + try testing.expect(active.y > 0); + + _ = s.compress(.full); + try testing.expect(!s.pages.first.?.isCompressed()); +} + +test "PageList compresses only complete cold history pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // More active rows than one page at these dimensions can hold ensures the + // active area spans multiple nodes when the pass chooses its boundary. + const active_rows = initialCapacity(80).rows + 1; + var s = try init(alloc, 80, active_rows, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + // Move the active top into the boundary page so it has both a historical + // prefix and active rows while the active area still spans later pages. + _ = try s.grow(); + + const active = s.getTopLeft(.active); + const active_node = active.node; + try testing.expect(active.y > 0); + try testing.expect(active_node != s.pages.last.?); + + var expected_compressed: usize = 0; + var expected_raw_bytes: usize = 0; + var current = s.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + expected_compressed += 1; + expected_raw_bytes += node.metadata().memory.len; + } + try testing.expectEqual(@as(usize, 2), expected_compressed); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const expected = try alloc.dupe(u8, first.page().memory); + defer alloc.free(expected); + const first_memory = first.page().memory.ptr; + const page_size = s.page_size; + + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + current = s.pages.first; + var actual_encoded_bytes: usize = 0; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + try testing.expect(node.isCompressed()); + actual_encoded_bytes += node.data.compressed.encoded.len; + } + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); + current = active_node; + while (current) |node| : (current = node.next) { + try testing.expect(!node.isCompressed()); + } + + // Restoring the oldest page preserves both the mapping identity and all + // of its bytes even though the pass discarded its physical pages. + try testing.expectEqual(first_memory, first.metadata().memory.ptr); + try testing.expectEqualSlices(u8, expected, first.page().memory); + try testing.expectEqual(first_memory, first.page().memory.ptr); +} + +test "PageList lazily restores compressed history made active by resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const memory_ptr = first.page().memory.ptr; + const memory_len = first.page().memory.len; + const page_size = s.page_size; + + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + + // Pull all scrollback into the active area by making the viewport as tall + // as the complete screen. A row-only resize needs only page metadata, so + // the newly active page can remain compressed until its contents are used. + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + const active = s.getTopLeft(.active); + try testing.expectEqual(first, active.node); + try testing.expectEqual(@as(size.CellCountInt, 0), active.y); + try testing.expect(first.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + + // The compression pass must not reconsider the node now that it is active. + // Content access follows the normal page boundary, which recommits and + // restores the retained mapping before returning the cell. + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + const cell = s.getCell(.{ .active = .{} }).?; + try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(memory_ptr, first.page().memory.ptr); + try testing.expectEqual(memory_len, first.page().memory.len); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList full and incremental compression skip a spanning viewport" { + const testing = std.testing; + + var full = try init(testing.allocator, 80, 24, null); + defer full.deinit(); + try full.growColdPagesForTest(3); + + var incremental = try init(testing.allocator, 80, 24, null); + defer incremental.deinit(); + try incremental.growColdPagesForTest(3); + + // Start near the end of the first page so the viewport intersects both + // the first and second historical page mappings. + const first = full.pages.first.?; + const overlap_rows: usize = full.rows / 2; + const viewport_row: usize = first.rows() - overlap_rows; + full.scroll(.{ .row = viewport_row }); + incremental.scroll(.{ .row = viewport_row }); + try testing.expect( + full.getTopLeft(.viewport).node != + full.getBottomRight(.viewport).?.node, + ); + const second = first.next.?; + try testing.expectEqual(first, full.getTopLeft(.viewport).node); + try testing.expectEqual(second, full.getBottomRight(.viewport).?.node); + + _ = full.compress(.full); + _ = incremental.compress(.drain); + try testing.expectEqual(full.memoryStats(), incremental.memoryStats()); + try testing.expect(!first.isCompressed()); + try testing.expect(!second.isCompressed()); + + const full_active = full.getTopLeft(.active).node; + const incremental_active = incremental.getTopLeft(.active).node; + var full_node = full.pages.first.?; + var incremental_node = incremental.pages.first.?; + while (full_node != full_active) { + try testing.expectEqual( + full_node.isCompressed(), + incremental_node.isCompressed(), + ); + + full_node = full_node.next.?; + incremental_node = incremental_node.next.?; + } + try testing.expectEqual(incremental_active, incremental_node); + + var eligible: CompressionIterator = .init(&full); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); +} + +test "PageList cold compression continues after an incompressible page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745); + prng.random().bytes(first.page().memory); + + const page_size = s.page_size; + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); + try testing.expect(!first.isCompressed()); + try testing.expect(second.isCompressed()); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + // Failed resident candidates are deliberately retried on later passes, + // while the successful page remains compressed and is skipped. + _ = s.compress(.full); + try testing.expectEqual(memory, s.memoryStats()); +} + +test "PageList compression restores through page access" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const page = node.page(); + page.dirty = true; + page.getRowAndCell(3, 2).cell.* = .init('X'); + + const expected = try alloc.dupe(u8, page.memory); + defer alloc.free(expected); + const memory_ptr = page.memory.ptr; + const memory_len = page.memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 24), node.rows()); + try testing.expectEqual(@as(size.CellCountInt, 80), node.cols()); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + try testing.expectEqual(page_size, s.page_size); + + // Pin access restores the page without changing its retained mapping. + const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 }; + try testing.expectEqual( + @as(u21, 'X'), + page_pin.rowAndCell().cell.content.codepoint, + ); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(node.page().dirty); + + // Recompressing exercises reuse of the page-pool scratch item. Page + // iterator chunks also restore before exposing row memory. + try testing.expect(s.compressPage(node)); + var page_it = (Pin{ .node = node }).pageIterator(.right_down, null); + const chunk = page_it.next().?; + try testing.expectEqual(node.rows(), chunk.rows().len); + try testing.expect(!node.isCompressed()); + try testing.expectEqualSlices(u8, expected, node.page().memory); + + // Read-only PageList operations restore through the same boundary. + try testing.expect(s.compressPage(node)); + var cloned = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer cloned.deinit(); + try testing.expect(!node.isCompressed()); + try testing.expectEqual( + @as(u21, 'X'), + cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint, + ); +} + +test "PageList compression uses temporary scratch for oversized pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + var node = s.pages.first.?; + while (node.page().memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + + const expected = try alloc.dupe(u8, node.page().memory); + defer alloc.free(expected); + const memory_ptr = node.page().memory.ptr; + const memory_len = node.page().memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); +} + +test "PageList compression leaves incompressible pages resident" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const original = try alloc.dupe(u8, node.page().memory); + defer alloc.free(original); + defer @memcpy(node.page().memory, original); + + var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354); + prng.random().bytes(node.page().memory); + const page_size = s.page_size; + + try testing.expect(!s.compressPage(node)); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList reset discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.reset(); + try testing.expect(!s.pages.first.?.isCompressed()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList deinit discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.deinit(); +} + +test "PageList prune reuses malformed compressed page memory" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + // Allocate the second page so the first one can be pruned and reused. + while (s.pages.first == s.pages.last) _ = try s.grow(); + const first = s.pages.first.?; + try testing.expect(s.compressPage(first)); + @memset(first.data.compressed.encoded, 0xFF); + + var reused = false; + const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1; + for (0..growth_limit) |_| { + if (try s.grow()) |new_node| { + if (new_node == first) { + reused = true; + break; + } + } + } + + try testing.expect(reused); + try testing.expectEqual(first, s.pages.last.?); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 1), first.rows()); + first.page().assertIntegrity(); +} + test "PageList" { const testing = std.testing; const alloc = testing.allocator; @@ -5710,7 +8046,7 @@ test "PageList init more than max cols" { // We expect a single, non-standard page try testing.expect(s.pages.first != null); - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Initial total rows should be our row count try testing.expectEqual(s.rows, s.total_rows); @@ -5794,17 +8130,17 @@ test "PageList pointFromPin active from prior page" { var s = try init(alloc, 80, 24, null); defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { try testing.expectEqual(point.Point{ @@ -5837,17 +8173,17 @@ test "PageList pointFromPin traverse pages" { defer s.deinit(); // Grow so we take up at least 2 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 2) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { const pages = s.totalPages(); @@ -5875,6 +8211,44 @@ test "PageList pointFromPin traverse pages" { }) == null); } } + +test "PageList pointFromPin rejects overflowing screen coordinate" { + const testing = std.testing; + + // Use maximum-height metadata-only pages to model a valid scrollback just + // beyond the u32 coordinate range without allocating their backing cells. + const page_count = 65_539; + const rows_per_page = std.math.maxInt(size.CellCountInt); + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + var s: PageList = undefined; + s.pages = .{ + .first = &nodes[0], + .last = &nodes[nodes.len - 1], + }; + + try testing.expect(s.pointFromPin(.screen, .{ + .node = &nodes[nodes.len - 1], + .y = 0, + .x = 0, + }) == null); +} + test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; @@ -5937,8 +8311,8 @@ test "PageList grow allows exceeding max size for active area" { { var it = s.pages.first; while (it) |page| : (it = page.next) { - page.data.size.rows = 1; - page.data.capacity.rows = 1; + page.page().size.rows = 1; + page.page().capacity.rows = 1; } // Avoid integrity check failures @@ -5967,7 +8341,7 @@ test "PageList grow prune required with a single page" { // This is important because it triggers a scenario where our calculated // minSize() which is supposed to accommodate 2 pages is no longer true. while (true) { - const layout = Page.layout(s.pages.first.?.data.capacity); + const layout = Page.layout(s.pages.first.?.capacity()); if (layout.total_size > std_size) break; _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } @@ -5980,7 +8354,7 @@ test "PageList grow prune required with a single page" { // page. const rem = rem: { const page = s.pages.first.?; - break :rem page.data.capacity.rows - page.data.size.rows; + break :rem page.capacity().rows - page.rows(); }; for (0..rem) |_| try testing.expect(try s.grow() == null); @@ -6205,6 +8579,20 @@ test "PageList scroll delta row back overflow" { }, s.scrollbar()); } +test "PageList scroll minimum row delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + // Create one row of history so scrolling all the way back has an + // observable result. + try s.growRows(1); + s.scroll(.{ .delta_row = std.math.minInt(isize) }); + + try testing.expectEqual(Viewport.top, s.viewport); +} + test "PageList scroll delta row forward" { const testing = std.testing; const alloc = testing.allocator; @@ -6870,7 +9258,7 @@ test "PageList: jump zero prompts" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -6890,6 +9278,16 @@ test "PageList: jump zero prompts" { }, s.scrollbar()); } +test "PageList: jump minimum prompt delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + s.scroll(.{ .delta_prompt = std.math.minInt(isize) }); + try testing.expectEqual(Viewport.active, s.viewport); +} + test "Screen: jump back one prompt" { const testing = std.testing; const alloc = testing.allocator; @@ -6898,7 +9296,7 @@ test "Screen: jump back one prompt" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -7016,7 +9414,7 @@ test "PageList grow fit in capacity" { defer s.deinit(); // So we know we're using capacity to grow - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); try testing.expect(last.size.rows < last.capacity.rows); // Grow @@ -7039,7 +9437,7 @@ test "PageList grow allocate" { // Grow to capacity const last_node = s.pages.last.?; - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); for (0..last.capacity.rows - last.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7058,6 +9456,47 @@ test "PageList grow allocate" { } } +test "PageList Cell screenPoint supports long scrollback" { + const testing = std.testing; + + // A modest number of full-size page nodes is enough to exceed the u16 + // row range without allocating any page backing memory. screenPoint only + // reads the linked metadata while calculating the absolute coordinate. + const page_count = 307; + const rows_per_page = std_capacity.rows; + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + const expected_y: u32 = (page_count - 1) * @as(u32, rows_per_page); + try testing.expect(expected_y > std.math.maxInt(size.CellCountInt)); + + const cell: Cell = .{ + .node = &nodes[nodes.len - 1], + .row = undefined, + .cell = undefined, + .row_idx = 0, + .col_idx = 0, + }; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = expected_y, + } }, cell.screenPoint()); +} + test "PageList grow prune scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7068,14 +9507,14 @@ test "PageList grow prune scrollback" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7137,14 +9576,14 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" { // Grow to capacity of first page const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate second page, then fill it up const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7195,7 +9634,7 @@ test "PageList eraseRows invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7235,7 +9674,7 @@ test "PageList eraseRow invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7274,7 +9713,7 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7306,6 +9745,53 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { }, s.scrollbar()); } +test "PageList row erasure renews affected page generations" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + while (s.totalPages() < 2) _ = try s.grow(); + + const first = s.pages.first.?; + const second = first.next.?; + var first_serial = first.serial; + var second_serial = second.serial; + var activity = s.page_compression.activity_serial; + + try s.eraseRow(.{ .history = .{ .y = 0 } }); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + first_serial = first.serial; + second_serial = second.serial; + activity = s.page_compression.activity_serial; + try s.eraseRowBounded( + .{ .history = .{ .y = 0 } }, + first.rows() + 1, + ); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); +} + +test "PageList trailing row truncation renews page generation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.last.?; + const old_serial = node.serial; + const trimmed = s.trimTrailingBlankRows(1); + s.total_rows -= trimmed; + try testing.expectEqual(@as(size.CellCountInt, 1), trimmed); + try testing.expect(!s.nodeIsValid(node, old_serial)); + + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { const testing = std.testing; const alloc = testing.allocator; @@ -7314,7 +9800,7 @@ test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7355,7 +9841,7 @@ test "PageList eraseRowBounded full page shift invalidates viewport offset cache defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 4) |_| { @@ -7398,7 +9884,7 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7442,11 +9928,11 @@ test "PageList increaseCapacity to increase styles" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_styles_cap = s.pages.first.?.data.capacity.styles; + const original_styles_cap = s.pages.first.?.capacity().styles; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write all our data so we can assert its the same after for (0..s.rows) |y| { @@ -7465,7 +9951,7 @@ test "PageList increaseCapacity to increase styles" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Verify capacity doubled try testing.expectEqual( @@ -7493,11 +9979,11 @@ test "PageList increaseCapacity to increase graphemes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.grapheme_bytes; + const original_cap = s.pages.first.?.capacity().grapheme_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7514,7 +10000,7 @@ test "PageList increaseCapacity to increase graphemes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes); @@ -7537,11 +10023,11 @@ test "PageList increaseCapacity to increase hyperlinks" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes; + const original_cap = s.pages.first.?.capacity().hyperlink_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7558,7 +10044,7 @@ test "PageList increaseCapacity to increase hyperlinks" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes); @@ -7581,11 +10067,11 @@ test "PageList increaseCapacity to increase string_bytes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.string_bytes; + const original_cap = s.pages.first.?.capacity().string_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7602,7 +10088,7 @@ test "PageList increaseCapacity to increase string_bytes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.string_bytes); @@ -7657,7 +10143,7 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { ) catch |err| { // Before OutOfSpace, we should have reached maxInt try testing.expectEqual(error.OutOfSpace, err); - try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles); + try testing.expectEqual(max_styles, s.pages.first.?.capacity().styles); break; }; } @@ -7675,7 +10161,7 @@ test "PageList increaseCapacity after col shrink" { try testing.expectEqual(5, s.cols); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(5, page.size.cols); try testing.expect(page.capacity.cols >= 10); } @@ -7684,7 +10170,7 @@ test "PageList increaseCapacity after col shrink" { _ = try s.increaseCapacity(s.pages.first.?, .styles); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // size.cols should still be 5, not reverted to capacity.cols try testing.expectEqual(5, page.size.cols); try testing.expectEqual(5, s.cols); @@ -7700,19 +10186,19 @@ test "PageList increaseCapacity multi-page" { // Grow to create a second page const page1_node = s.pages.last.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Now we have two pages try testing.expect(s.pages.first != s.pages.last); const page2_node = s.pages.last.?; - const page1_styles_cap = s.pages.first.?.data.capacity.styles; - const page2_styles_cap = page2_node.data.capacity.styles; + const page1_styles_cap = s.pages.first.?.capacity().styles; + const page2_styles_cap = page2_node.capacity().styles; // Increase capacity on the first page only _ = try s.increaseCapacity(s.pages.first.?, .styles); @@ -7720,13 +10206,13 @@ test "PageList increaseCapacity multi-page" { // First page capacity should be doubled try testing.expectEqual( page1_styles_cap * 2, - s.pages.first.?.data.capacity.styles, + s.pages.first.?.capacity().styles, ); // Second page should be unchanged try testing.expectEqual( page2_styles_cap, - s.pages.last.?.data.capacity.styles, + s.pages.last.?.capacity().styles, ); } @@ -7738,7 +10224,7 @@ test "PageList increaseCapacity preserves dirty flag" { defer s.deinit(); // Set page dirty flag and mark some rows as dirty - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.dirty = true; const rows = page.rows.ptr(page.memory); @@ -7751,10 +10237,10 @@ test "PageList increaseCapacity preserves dirty flag" { const new_node = try s.increaseCapacity(s.pages.first.?, .styles); // The page dirty flag should be preserved - try testing.expect(new_node.data.dirty); + try testing.expect(new_node.page().dirty); // Row dirty flags should be preserved - const new_rows = new_node.data.rows.ptr(new_node.data.memory); + const new_rows = new_node.page().rows.ptr(new_node.page().memory); try testing.expect(new_rows[0].dirty); try testing.expect(!new_rows[1].dirty); try testing.expect(new_rows[2].dirty); @@ -7793,12 +10279,12 @@ test "PageList pageIterator two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7806,9 +10292,9 @@ test "PageList pageIterator two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); } { const chunk = it.next().?; @@ -7829,12 +10315,12 @@ test "PageList pageIterator history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7882,12 +10368,12 @@ test "PageList pageIterator reverse two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7904,9 +10390,9 @@ test "PageList pageIterator reverse two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); count += chunk.end - chunk.start; } try testing.expect(it.next() == null); @@ -7922,12 +10408,12 @@ test "PageList pageIterator reverse history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7943,6 +10429,76 @@ test "PageList pageIterator reverse history two pages" { try testing.expect(it.next() == null); } +test "PageList PageIterator reverse count includes row zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, null); + defer s.deinit(); + + var it: PageIterator = .{ + .row = s.getTopLeft(.screen), + .limit = .{ .count = 1 }, + .direction = .left_up, + }; + const chunk = it.next().?; + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + try testing.expect(it.next() == null); +} + +test "PageList PageIterator count crosses page boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const first = s.pages.first.?; + first.page().pauseIntegrityChecks(true); + while (first.rows() < first.capacity().rows) _ = try s.grow(); + first.page().pauseIntegrityChecks(false); + const second = (try s.grow()).?; + + var down: PageIterator = .{ + .row = .{ .node = first, .y = first.rows() - 1 }, + .limit = .{ .count = 2 }, + .direction = .right_down, + }; + { + const chunk = down.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + { + const chunk = down.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + try testing.expect(down.next() == null); + + var up: PageIterator = .{ + .row = .{ .node = second }, + .limit = .{ .count = 2 }, + .direction = .left_up, + }; + { + const chunk = up.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + { + const chunk = up.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + try testing.expect(up.next() == null); +} + test "PageList cellIterator" { const testing = std.testing; const alloc = testing.allocator; @@ -7950,7 +10506,7 @@ test "PageList cellIterator" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8000,7 +10556,7 @@ test "PageList cellIterator reverse" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8050,7 +10606,7 @@ test "PageList promptIterator left_up" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8107,7 +10663,7 @@ test "PageList promptIterator right_down" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8164,7 +10720,7 @@ test "PageList promptIterator right_down continuation at start" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { @@ -8207,7 +10763,7 @@ test "PageList promptIterator right_down with prompt before continuation" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 2, continuation on rows 3-4 // Starting iteration from row 3 should still find the prompt at row 2 @@ -8246,7 +10802,7 @@ test "PageList promptIterator right_down limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8278,7 +10834,7 @@ test "PageList promptIterator left_up limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8311,7 +10867,7 @@ test "PageList highlightSemanticContent prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8365,7 +10921,7 @@ test "PageList highlightSemanticContent prompt with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8430,7 +10986,7 @@ test "PageList highlightSemanticContent prompt multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt starts on row 5 { @@ -8486,7 +11042,7 @@ test "PageList highlightSemanticContent prompt only" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content (no input) { @@ -8530,7 +11086,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8578,7 +11134,7 @@ test "PageList highlightSemanticContent input basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8633,7 +11189,7 @@ test "PageList highlightSemanticContent input with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8698,7 +11254,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8775,7 +11331,7 @@ test "PageList highlightSemanticContent input no input returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt, then immediately output { @@ -8823,7 +11379,7 @@ test "PageList highlightSemanticContent input to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8871,7 +11427,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content, no input or output { @@ -8918,7 +11474,7 @@ test "PageList highlightSemanticContent output basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8989,7 +11545,7 @@ test "PageList highlightSemanticContent output multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9080,7 +11636,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9165,7 +11721,7 @@ test "PageList highlightSemanticContent output to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -9237,7 +11793,7 @@ test "PageList highlightSemanticContent output no output returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt and input, no output { @@ -9297,7 +11853,7 @@ test "PageList highlightSemanticContent output skips empty cells" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { @@ -9376,17 +11932,17 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large @@ -9410,17 +11966,17 @@ test "PageList erase reaccounts page size" { const start_size = s.page_size; // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expect(s.page_size > start_size); // Erase the entire history, we should be back to just our active set. @@ -9436,17 +11992,17 @@ test "PageList erase row with tracked pin resets to top-left" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Our total rows should be large try testing.expect(s.total_rows > s.rows); @@ -9515,17 +12071,17 @@ test "PageList erase resets viewport to active if moves within active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9544,17 +12100,17 @@ test "PageList erase resets viewport if inside erased page but not active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9573,17 +12129,17 @@ test "PageList erase resets viewport to active if top is inside active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .top = {} }); @@ -9613,7 +12169,7 @@ test "PageList erase a one-row active" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Write our letter - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -9736,13 +12292,13 @@ test "PageList eraseRowBounded full rows two pages" { // Grow to two pages so our active area straddles { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); page.pauseIntegrityChecks(true); for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow(); page.pauseIntegrityChecks(false); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); - try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows); + try testing.expectEqual(@as(usize, 5), s.pages.last.?.rows()); } // Pins @@ -9757,11 +12313,11 @@ test "PageList eraseRowBounded full rows two pages" { { try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 1), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 1), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); try testing.expectEqual(s.pages.last.?, p_in.node); @@ -9784,12 +12340,12 @@ test "PageList eraseRowBounded full rows two pages" { // In page in first page is shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 2), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 2), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); // Out page in first page should not be shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); // In page is shifted @@ -9863,7 +12419,7 @@ test "PageList clone partial trimmed left reclaims styles" { // Style the rows we're trimming { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; const style_id = try page.styles.add(page.memory, style); @@ -9895,7 +12451,7 @@ test "PageList clone partial trimmed left reclaims styles" { { try testing.expect(s2.pages.first == s2.pages.last); - const page = &s2.pages.first.?.data; + const page = s2.pages.first.?.page(); try testing.expectEqual(0, page.styles.count()); } } @@ -10093,7 +12649,7 @@ test "PageList resize (no reflow) less rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10127,7 +12683,7 @@ test "PageList resize (no reflow) one rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10161,7 +12717,7 @@ test "PageList resize (no reflow) less rows cursor on bottom" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10213,7 +12769,7 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10264,7 +12820,7 @@ test "PageList resize (no reflow) less rows trims blank lines" { var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10323,7 +12879,7 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10366,7 +12922,7 @@ test "PageList resize (no reflow) less rows trims blank lines erases pages" { var s = try init(alloc, 100, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Resize to take up two pages { @@ -10399,7 +12955,7 @@ test "PageList resize (no reflow) more rows extends blank lines" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10479,7 +13035,7 @@ test "PageList resize (no reflow) less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10503,7 +13059,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -10521,7 +13077,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { defer s.deinit(); // Add a grapheme. - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(9, 0); rac.cell.* = .{ @@ -10539,7 +13095,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount()); + try testing.expectEqual(@as(usize, 0), chunk.node.page().graphemeCount()); } } @@ -10558,7 +13114,7 @@ test "PageList resize (no reflow) more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10571,7 +13127,7 @@ test "PageList resize (no reflow) more cols with spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10614,7 +13170,7 @@ test "PageList resize (no reflow) more cols with spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10654,7 +13210,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Place a spacer_head at the last column (col 4) on two rows // to simulate a wide character that didn't fit at the right edge. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true { @@ -10693,7 +13249,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Verify the old spacer_head positions are now narrow. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(4, 0); try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); @@ -10749,14 +13305,14 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } // Now we need to resize again to a col size that further shrinks // our last capacity. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expect(page.size.rows == page.capacity.rows); const new_cols = new_cols: { var new_cols = page.size.cols + 50; @@ -10779,7 +13335,7 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } } @@ -10803,7 +13359,7 @@ test "PageList resize (no reflow) less cols then more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10823,7 +13379,7 @@ test "PageList resize (no reflow) less rows and cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10841,8 +13397,8 @@ test "PageList resize less rows and cols cursor at bottom" { } }).?); defer s.untrackPin(cursor_pin); - // Shrink both axes so the original cursor.y is strictly past the new row - // count, after resizeWithoutReflow has already reduced self.rows. + // Shrink both axes such that the original cursor.y is strictly past the + // new row count, so resizeWithoutReflow leaves self.rows < c.y + 1. try s.resize(.{ .cols = 79, .rows = 20, @@ -10859,6 +13415,58 @@ test "PageList resize less rows and cols cursor at bottom" { } }, s.pointFromPin(.active, cursor_pin.*).?); } +test "PageList resize less rows and cols cursor near top pushed to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Fill every active row with non-blank content so that shrinking rows + // can't trim trailing blank lines and instead pushes the top rows into + // scrollback. + { + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cells = p.node.page().getCells(rac.row); + for (cells, 0..) |*cell, x| cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast('A' + (x % 26)) }, + }; + } + } + + // Cursor near the top of the active area. After we shrink rows the active + // area top moves down past this pin, so it ends up in scrollback. + const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?); + defer s.untrackPin(cursor_pin); + + // Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows + // first, leaving the cursor pin above the new active area, then resizeCols + // walks .left_up from the cursor pin toward the active-area top. + try s.resize(.{ + .cols = 79, + .rows = 20, + .reflow = true, + .cursor = .{ .x = 0, .y = 0, .pin = cursor_pin }, + }); + try testing.expectEqual(@as(usize, 79), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + + // The active area is anchored to the bottom, so shrinking rows pushed the + // top-of-screen cursor into scrollback: it no longer resolves to an + // active-area coordinate, but it remains a valid screen pin. + try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null); + try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null); + + // Integrity must hold after the resize. + s.assertIntegrity(); +} + test "PageList resize (no reflow) more rows and less cols" { const testing = std.testing; const alloc = testing.allocator; @@ -10875,7 +13483,7 @@ test "PageList resize (no reflow) more rows and less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10915,7 +13523,7 @@ test "PageList resize (no reflow) empty screen" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10934,7 +13542,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var s = try init(alloc, cap.cols, cap.rows, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -10954,7 +13562,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, cap2.cols), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -10970,7 +13578,7 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" // Grow to 5 total rows, simulating 3 active + 2 scrollback try s.growRows(2); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.totalRows()) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -11040,7 +13648,7 @@ test "PageList resize reflow more cols no wrapped rows" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -11059,7 +13667,7 @@ test "PageList resize reflow more cols no wrapped rows" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -11072,7 +13680,7 @@ test "PageList resize reflow more cols wrapped rows" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11110,7 +13718,7 @@ test "PageList resize reflow more cols wrapped rows" { // First row should be unwrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 4), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -11126,7 +13734,7 @@ test "PageList resize reflow invalidates viewport offset cache" { defer s.deinit(); try s.growRows(20); - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11188,7 +13796,7 @@ test "PageList resize reflow more cols creates multiple pages" { // Wrap every other row so every line is wrapped for reflow { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11220,8 +13828,8 @@ test "PageList resize reflow more cols creates multiple pages" { count += 1; // All pages should have the new capacity - try testing.expectEqual(newcap.cols, page.data.capacity.cols); - try testing.expectEqual(newcap.rows, page.data.capacity.rows); + try testing.expectEqual(newcap.cols, page.capacity().cols); + try testing.expectEqual(newcap.rows, page.capacity().rows); } // We should have more than one page, meaning we created at least @@ -11241,7 +13849,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11255,7 +13863,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11270,7 +13878,7 @@ test "PageList resize reflow more cols wrap across page boundary" { } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11372,7 +13980,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11386,7 +13994,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11401,7 +14009,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11458,7 +14066,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11472,7 +14080,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11487,7 +14095,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11634,7 +14242,7 @@ test "PageList resize reflow more cols cursor in wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11685,7 +14293,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11736,7 +14344,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11802,7 +14410,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; } @@ -11814,7 +14422,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -11831,7 +14439,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11865,7 +14473,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 0 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11889,7 +14497,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 1 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11925,7 +14533,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11959,7 +14567,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1); rac.row.wrap = true; rac.cell.* = .{ @@ -11992,7 +14600,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.wrap = true; rac.cell.* = .{ @@ -12037,7 +14645,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12055,7 +14663,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Give each cell in the final row of the first page a unique style. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12082,7 +14690,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Do the same for the first row of the second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12118,7 +14726,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12162,7 +14770,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12191,7 +14799,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12251,7 +14859,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12295,7 +14903,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12338,7 +14946,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12376,7 +14984,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -12419,7 +15027,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12431,7 +15039,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12445,7 +15053,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12457,7 +15065,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12470,7 +15078,7 @@ test "PageList resize reflow less cols no wrapped rows" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const end = 4; assert(end < s.cols); @@ -12494,7 +15102,7 @@ test "PageList resize reflow less cols no wrapped rows" { var offset_copy = offset; offset_copy.x = @intCast(x); const rac = offset_copy.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); } @@ -12508,7 +15116,7 @@ test "PageList resize reflow less cols wrapped rows" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12538,7 +15146,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12546,7 +15154,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12555,7 +15163,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12563,7 +15171,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12578,7 +15186,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12608,13 +15216,13 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12622,7 +15230,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12636,7 +15244,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12644,7 +15252,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12663,7 +15271,7 @@ test "PageList resize reflow less cols cursor in wrapped row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12698,7 +15306,7 @@ test "PageList resize reflow less cols wraps spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12756,7 +15364,7 @@ test "PageList resize reflow less cols wraps spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12793,7 +15401,7 @@ test "PageList resize reflow less cols cursor goes to scrollback" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12824,7 +15432,7 @@ test "PageList resize reflow less cols cursor in unchanged row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12858,7 +15466,7 @@ test "PageList resize reflow less cols cursor in blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12892,7 +15500,7 @@ test "PageList resize reflow less cols cursor in final blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12926,7 +15534,7 @@ test "PageList resize reflow less cols cursor in wrapped blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12960,7 +15568,7 @@ test "PageList resize reflow less cols blank lines" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..1) |y| { for (0..4) |x| { const rac = page.getRowAndCell(x, y); @@ -12981,7 +15589,7 @@ test "PageList resize reflow less cols blank lines" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12989,7 +15597,7 @@ test "PageList resize reflow less cols blank lines" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13003,7 +15611,7 @@ test "PageList resize reflow less cols blank lines between" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { for (0..4) |x| { const rac = page.getRowAndCell(x, 0); @@ -13037,7 +15645,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -13045,7 +15653,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13059,7 +15667,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); rac.cell.* = .{ @@ -13084,7 +15692,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -13092,13 +15700,13 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); } { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); @@ -13112,7 +15720,7 @@ test "PageList resize reflow less cols cursor not on last line preserves locatio var s = try init(alloc, 5, 5, 1); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13157,7 +15765,7 @@ test "PageList resize reflow less cols copy style" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -13191,8 +15799,8 @@ test "PageList resize reflow less cols copy style" { const style_id = rac.cell.style_id; try testing.expect(style_id != 0); - const style = offset.node.data.styles.get( - offset.node.data.memory, + const style = offset.node.page().styles.get( + offset.node.page().memory, style_id, ); try testing.expect(style.flags.bold); @@ -13211,7 +15819,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13238,7 +15846,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13256,7 +15864,7 @@ test "PageList resize reflow less cols to wrap a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13290,7 +15898,7 @@ test "PageList resize reflow less cols to wrap a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13324,7 +15932,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // We want to make the screen look like this: // @@ -13383,7 +15991,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13445,7 +16053,7 @@ test "PageList resize reflow less cols copy kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13486,7 +16094,7 @@ test "PageList resize reflow more cols clears kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13529,7 +16137,7 @@ test "PageList resize reflow wrap moves kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (2..s.cols - 1) |x| { @@ -13586,20 +16194,44 @@ test "PageList reset invalidates stale untracked refs even if node memory is reu var s = try init(alloc, 80, 24, null); defer s.deinit(); - const old_serial = s.pages.first.?.serial; - try testing.expect(old_serial >= s.page_serial_min); - try testing.expect(old_serial < s.page_serial); - - s.reset(); + var stale_nodes: [page_preheat * 4]*List.Node = undefined; + var stale_serials: [stale_nodes.len]u64 = undefined; + var stale_len: usize = 0; + var reused: ?struct { *List.Node, u64 } = null; + + while (stale_len < stale_nodes.len and reused == null) { + const old_node = s.pages.first.?; + const old_serial = old_node.serial; + try testing.expect(old_serial >= s.page_serial_epoch); + try testing.expect(old_serial < s.page_serial); + stale_nodes[stale_len] = old_node; + stale_serials[stale_len] = old_serial; + stale_len += 1; + + s.reset(); + + const new_node = s.pages.first.?; + for (stale_nodes[0..stale_len], stale_serials[0..stale_len]) |node, serial| { + if (node == new_node) { + reused = .{ node, serial }; + break; + } + } + } - // The important safety property is that stale serials are rejected before - // the node pointer is inspected. Reset rebuilds the page list from the - // pools, so old untracked refs may contain node pointers that are no - // longer safe to dereference. - try testing.expect(old_serial < s.page_serial_min); + try testing.expect(reused != null); + const old_node, const old_serial = reused.?; + const new_node = s.pages.first.?; + const new_serial = new_node.serial; - const new_serial = s.pages.first.?.serial; - try testing.expect(new_serial >= s.page_serial_min); + // Reset advances the epoch before rebuilding from the node pool. Reject + // the stale generation before inspecting its pointer, even when that exact + // address now belongs to a new live generation. + try testing.expectEqual(old_node, new_node); + try testing.expect(old_serial < s.page_serial_epoch); + try testing.expect(!s.nodeIsValid(old_node, old_serial)); + try testing.expect(s.nodeIsValid(new_node, new_serial)); + try testing.expect(new_serial >= s.page_serial_epoch); try testing.expect(new_serial < s.page_serial); } @@ -13685,7 +16317,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // during reflow. Since each source page can only hold this many graphemes, // we create two source pages with graphemes that will merge into one // destination page. - const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + const grapheme_capacity = s.pages.first.?.page().graphemeCapacity(); // Use slightly more than half the capacity per page, so combined they // exceed the capacity of a single destination page. const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; @@ -13693,7 +16325,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Grow to the capacity of the first page and add more rows // so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -13715,7 +16347,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the end of the first page (last rows) { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const start_row = page.size.rows - graphemes_per_page; for (0..graphemes_per_page) |i| { const y = start_row + i; @@ -13730,7 +16362,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the beginning of the second page { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const count = @min(graphemes_per_page, page.size.rows); for (0..count) |y| { const rac = page.getRowAndCell(0, y); @@ -13773,7 +16405,7 @@ test "PageList resize grow cols with unwrap fixes viewport pin" { // Fill all rows with wrapped content (pairs that unwrap when cols increase) var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -13817,16 +16449,16 @@ test "PageList grow reuses non-standard page without leak" { defer s.deinit(); // Increase the first page capacity to make it non-standard (larger than std_size). - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } // The first page should now have non-standard memory size. - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // First, fill up the first page's capacity const first_page = s.pages.first.?; - while (first_page.data.size.rows < first_page.data.capacity.rows) { + while (first_page.rows() < first_page.capacity().rows) { _ = try s.grow(); } @@ -13836,23 +16468,23 @@ test "PageList grow reuses non-standard page without leak" { // Continue growing until we exceed max_size AND the last page is full while (s.page_size + PagePool.item_size <= s.maxSize() or - s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows) + s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { _ = try s.grow(); } // The first page should still be non-standard - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Verify we have enough rows for active area (so prune path isn't skipped) try testing.expect(s.totalRows() >= s.rows); // Verify last page is full (so grow will need to allocate/reuse) - try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); + try testing.expect(s.pages.last.?.page().size.rows == s.pages.last.?.capacity().rows); // Remember the first page memory pointer before the reuse attempt const first_page_ptr = s.pages.first.?; - const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + const first_page_mem_ptr = s.pages.first.?.page().memory.ptr; // Create a tracked pin pointing to the non-standard first page const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 }); @@ -13871,7 +16503,7 @@ test "PageList grow reuses non-standard page without leak" { // If the non-standard page was properly destroyed and not reused, // the last page should not have the same memory pointer - try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); + try testing.expect(s.pages.last.?.page().memory.ptr != first_page_mem_ptr); // The tracked pin should have been moved to the new first page and marked as garbage try testing.expectEqual(s.pages.first.?, tracked_pin.node); @@ -13900,19 +16532,19 @@ test "PageList grow non-standard page prune protection" { defer s.deinit(); // Make the first page non-standard - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity( s.pages.first.?, .grapheme_bytes, ); } - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); const first_page_node = s.pages.first.?; - const first_page_cap = first_page_node.data.capacity.rows; + const first_page_cap = first_page_node.capacity().rows; // Fill first page to capacity - while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow(); + while (first_page_node.rows() < first_page_cap) _ = try s.grow(); // Grow until we have a second page (first page fills up first) var second_node: ?*List.Node = null; @@ -13921,8 +16553,8 @@ test "PageList grow non-standard page prune protection" { // Fill the second page to capacity so that the next grow() triggers prune const last_node = s.pages.last.?; - const second_cap = last_node.data.capacity.rows; - while (last_node.data.size.rows < second_cap) _ = try s.grow(); + const second_cap = last_node.capacity().rows; + while (last_node.rows() < second_cap) _ = try s.grow(); // Now the last page is full. The next grow must either: // 1. Prune the first page and reuse it, OR @@ -13939,7 +16571,7 @@ test "PageList grow non-standard page prune protection" { try testing.expect(s.totalRows() >= s.rows); // Verify last page is at capacity (so grow must prune or allocate new) - try testing.expectEqual(second_cap, last_node.data.size.rows); + try testing.expectEqual(second_cap, last_node.rows()); // The next grow should trigger prune consideration. // Without the fix, this would destroy the non-standard first page, @@ -13973,12 +16605,12 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { // Trim a history row so the first page has spare capacity. // This triggers the backfill path in resizeWithoutReflowGrowCols. s.eraseHistory(.{ .history = .{ .y = 0 } }); - try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows); + try testing.expect(first_page.rows() < first_page.capacity().rows); // Ensure the resize takes the slow path (new capacity > current capacity). const new_cols: size.CellCountInt = cols + 1; - const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols }); - try testing.expect(second_page.data.capacity.cols < adjusted.cols); + const adjusted = try second_page.capacity().adjust(.{ .cols = new_cols }); + try testing.expect(second_page.capacity().cols < adjusted.cols); // Track a pin in row 0 of the second page. This row will be copied // to the first page during backfill and the pin must be remapped. @@ -14005,7 +16637,7 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { } } try testing.expect(found); - try testing.expect(tracked.y < tracked.node.data.size.rows); + try testing.expect(tracked.y < tracked.node.rows()); // Verify the pin still points to the cell with our marker content. const cell = tracked.rowAndCell().cell; @@ -14013,23 +16645,108 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { try testing.expectEqual(marker, cell.content.codepoint); } -test "PageList compact std_size page returns null" { +test "PageList compact pool page produces exact-size heap page" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 80, 24, 0); defer s.deinit(); - // A freshly created page should be at std_size + // A freshly created page is pool-owned at std_size. const node = s.pages.first.?; - try testing.expect(node.data.memory.len <= std_size); + try testing.expectEqual(.pool, node.owned); + try testing.expect(node.page().memory.len <= std_size); + const original_size = node.page().size; + + // Compacting it should produce a much smaller exact-size heap page. + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < std_size); + try testing.expectEqual(original_size.rows, new_node.rows()); + try testing.expectEqual(original_size.cols, new_node.cols()); + try testing.expectEqual(new_node, s.pages.first.?); + + // Our page size accounting should exactly match the compacted + // page since it is the only page in the list. + try testing.expectEqual(new_node.page().memory.len, s.page_size); + + // Compacting again should be a no-op since it is already exact. + try testing.expectEqual(null, try s.compact(new_node)); +} + +test "PageList compact then grow allocates new page" { + const testing = std.testing; + const alloc = testing.allocator; - // compact should return null since there's nothing to compact - const result = try s.compact(node); - try testing.expectEqual(null, result); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Compact the only page. It now has no spare row capacity. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(node.rows(), node.capacity().rows); + + // Growing must allocate a fresh standard page from the pool, + // exercising that a compacted page remains a valid live page. + _ = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + try testing.expectEqual(.pool, s.pages.last.?.owned); + try testing.expectEqual(@as(usize, 25), s.totalRows()); +} + +test "PageList compact then reset frees heap pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page so the list contains a sub-std_size + // heap-owned page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + // Reset must free the heap page (testing allocator catches leaks + // and invalid frees) and rebuild from the pool. + s.reset(); + try testing.expectEqual(.pool, s.pages.first.?.owned); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + +test "PageList compact then clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Write a marker so we can verify contents survive. + { + const node = s.pages.first.?; + const rac = node.page().getRowAndCell(1, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'X' }, + }; + } - // Page should still be the same - try testing.expectEqual(node, s.pages.first.?); + // Compact so the source list contains a sub-std_size heap page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + var s2 = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); + + // Verify the marker survived the clone. + { + const node2 = s2.pages.first.?; + const rac = node2.page().getRowAndCell(1, 2); + try testing.expectEqual(@as(u21, 'X'), rac.cell.content.codepoint); + } } test "PageList compact oversized page" { @@ -14041,11 +16758,11 @@ test "PageList compact oversized page" { // Grow until we have multiple pages const page1_node = s.pages.first.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { _ = try s.grow(); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); _ = try s.grow(); try testing.expect(s.pages.first != s.pages.last); @@ -14053,7 +16770,7 @@ test "PageList compact oversized page" { // Write content to verify it's preserved { - const page = &node.data; + const page = node.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14070,30 +16787,30 @@ test "PageList compact oversized page" { defer s.untrackPin(tracked); // Make the page oversized - while (node.data.memory.len <= std_size) { + while (node.page().memory.len <= std_size) { node = try s.increaseCapacity(node, .grapheme_bytes); } - try testing.expect(node.data.memory.len > std_size); - const oversized_len = node.data.memory.len; - const original_size = node.data.size; + try testing.expect(node.page().memory.len > std_size); + const oversized_len = node.page().memory.len; + const original_size = node.page().size; const second_node = node.next.?; // Set dirty flag after increaseCapacity - node.data.dirty = true; + node.page().dirty = true; // Compact the page const new_node = try s.compact(node); try testing.expect(new_node != null); // Verify memory is smaller - try testing.expect(new_node.?.data.memory.len < oversized_len); + try testing.expect(new_node.?.page().memory.len < oversized_len); // Verify size preserved - try testing.expectEqual(original_size.rows, new_node.?.data.size.rows); - try testing.expectEqual(original_size.cols, new_node.?.data.size.cols); + try testing.expectEqual(original_size.rows, new_node.?.rows()); + try testing.expectEqual(original_size.cols, new_node.?.cols()); // Verify dirty flag preserved - try testing.expect(new_node.?.data.dirty); + try testing.expect(new_node.?.page().dirty); // Verify linked list integrity try testing.expectEqual(new_node.?, s.pages.first.?); @@ -14107,7 +16824,7 @@ test "PageList compact oversized page" { try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y); // Verify content preserved - const page = &new_node.?.data; + const page = new_node.?.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14119,7 +16836,73 @@ test "PageList compact oversized page" { } } -test "PageList compact insufficient savings returns null" { +test "PageList destroyed pool page reuse is zeroed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a page and scribble over its entire backing memory, + // then destroy it so the buffer returns to the pool free list. + const node = try s.createPage(.{ .cap = initialCapacity(80) }); + node.page().size.rows = 1; + const mem_ptr = node.page().memory.ptr; + @memset(node.page().memory, 0xAA); + s.destroyNode(node); + + // Reusing the buffer must produce a fully valid, zeroed page. + const node2 = try s.createPage(.{ .cap = initialCapacity(80) }); + try testing.expectEqual(mem_ptr, node2.page().memory.ptr); + node2.page().size.rows = node2.capacity().rows; + + const cells_len = @as(usize, node2.capacity().cols) * + @as(usize, node2.capacity().rows); + const cells = node2.page().cells.ptr(node2.page().memory)[0..cells_len]; + try testing.expect(std.mem.allEqual( + u64, + @as([]const u64, @ptrCast(cells)), + 0, + )); + node2.page().assertIntegrity(); + s.destroyNode(node2); +} + +test "PageList increaseCapacity from zero-capacity dimensions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page. A plain page has no styled, grapheme, + // or hyperlink content so the exact capacity is zero in every + // managed dimension. + var node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); + + // Increasing each dimension from zero must actually grow it. + // Regression: 0 * 2 == 0 used to "succeed" without growing, + // which turned caller retry loops into infinite loops. + node = try s.increaseCapacity(node, .styles); + try testing.expect(node.capacity().styles > 0); + node = try s.increaseCapacity(node, .grapheme_bytes); + try testing.expect(node.capacity().grapheme_bytes > 0); + node = try s.increaseCapacity(node, .string_bytes); + try testing.expect(node.capacity().string_bytes > 0); + node = try s.increaseCapacity(node, .hyperlink_bytes); + try testing.expect(node.capacity().hyperlink_bytes > 0); + + // Increasing a non-zero dimension still doubles. + const styles = node.capacity().styles; + node = try s.increaseCapacity(node, .styles); + try testing.expectEqual(styles * 2, node.capacity().styles); +} + +test "PageList compact after increaseCapacity" { const testing = std.testing; const alloc = testing.allocator; @@ -14128,23 +16911,15 @@ test "PageList compact insufficient savings returns null" { var node = s.pages.first.?; - // Make the page slightly oversized (just one increase) - // This might not provide enough savings to justify compaction + // Grow the page capacity. The content is unchanged, so compaction + // should always shrink it back down to an exact-size heap page. node = try s.increaseCapacity(node, .grapheme_bytes); + const grown_len = node.page().memory.len; - // If the page is still at or below std_size, compact returns null - if (node.data.memory.len <= std_size) { - const result = try s.compact(node); - try testing.expectEqual(null, result); - } else { - // If it did grow beyond std_size, verify that compaction - // works or returns null based on savings calculation - const result = try s.compact(node); - // Either it compacted or determined insufficient savings - if (result) |new_node| { - try testing.expect(new_node.data.memory.len < node.data.memory.len); - } - } + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < grown_len); + try testing.expect(new_node.page().memory.len < std_size); } test "PageList split at middle row" { @@ -14154,7 +16929,7 @@ test "PageList split at middle row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc. for (0..page.size.rows) |y| { @@ -14173,8 +16948,8 @@ test "PageList split at middle row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have rows 0-4 (5 rows) try testing.expectEqual(@as(usize, 5), first_page.size.rows); @@ -14201,7 +16976,7 @@ test "PageList split at row 0 is no-op" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14235,7 +17010,7 @@ test "PageList split at last row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14254,8 +17029,8 @@ test "PageList split at last row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have 9 rows try testing.expectEqual(@as(usize, 9), first_page.size.rows); @@ -14441,8 +17216,8 @@ test "PageList split middle page preserves linked list order" { // Now we have 2 pages const page1 = s.pages.first.?; const page2 = s.pages.first.?.next.?; - try testing.expectEqual(@as(usize, 4), page1.data.size.rows); - try testing.expectEqual(@as(usize, 8), page2.data.size.rows); + try testing.expectEqual(@as(usize, 4), page1.rows()); + try testing.expectEqual(@as(usize, 8), page2.rows()); // Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7) const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 }; @@ -14469,9 +17244,9 @@ test "PageList split middle page preserves linked list order" { try testing.expect(last.next == null); // Verify row counts - try testing.expectEqual(@as(usize, 4), first.data.size.rows); - try testing.expectEqual(@as(usize, 4), middle.data.size.rows); - try testing.expectEqual(@as(usize, 4), last.data.size.rows); + try testing.expectEqual(@as(usize, 4), first.rows()); + try testing.expectEqual(@as(usize, 4), middle.rows()); + try testing.expectEqual(@as(usize, 4), last.rows()); } test "PageList split last page makes new page the last" { @@ -14489,7 +17264,7 @@ test "PageList split last page makes new page the last" { // Now split the last page const last_before_split = s.pages.last.?; - try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows); + try testing.expectEqual(@as(usize, 5), last_before_split.rows()); const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 }; try s.split(split_pin2); @@ -14501,8 +17276,8 @@ test "PageList split last page makes new page the last" { try testing.expect(new_last.next == null); // Verify row counts: original last has 2 rows, new last has 3 rows - try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows); - try testing.expectEqual(@as(usize, 3), new_last.data.size.rows); + try testing.expectEqual(@as(usize, 2), last_before_split.rows()); + try testing.expectEqual(@as(usize, 3), new_last.rows()); } test "PageList split first page keeps original as first" { @@ -14534,9 +17309,9 @@ test "PageList split first page keeps original as first" { try testing.expectEqual(second_page, inserted.next.?); // Verify row counts: first has 2, inserted has 3, second has 5 - try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows); - try testing.expectEqual(@as(usize, 3), inserted.data.size.rows); - try testing.expectEqual(@as(usize, 5), second_page.data.size.rows); + try testing.expectEqual(@as(usize, 2), s.pages.first.?.rows()); + try testing.expectEqual(@as(usize, 3), inserted.rows()); + try testing.expectEqual(@as(usize, 5), second_page.rows()); } test "PageList split preserves wrap flags" { @@ -14546,7 +17321,7 @@ test "PageList split preserves wrap flags" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Set wrap flags on rows that will be in the second page after split // Row 5: wrap = true (this is the start of a wrapped line) @@ -14568,7 +17343,7 @@ test "PageList split preserves wrap flags" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const second_page = &s.pages.first.?.next.?.data; + const second_page = s.pages.first.?.next.?.page(); // Verify wrap flags are preserved in new page // Original row 5 is now row 0 in second page @@ -14600,7 +17375,7 @@ test "PageList split preserves styled cells" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style and apply it to cells in rows 5-7 (which will be in the second page) const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -14623,8 +17398,8 @@ test "PageList split preserves styled cells" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no styles (all styled rows moved to second page) try testing.expectEqual(@as(usize, 0), first_page.styles.count()); @@ -14651,7 +17426,7 @@ test "PageList split preserves grapheme clusters" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5) { @@ -14670,8 +17445,8 @@ test "PageList split preserves grapheme clusters" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no graphemes (the grapheme row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.graphemeCount()); @@ -14699,7 +17474,7 @@ test "PageList split preserves hyperlinks" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a hyperlink to row 7 (will be row 2 in second page after split at 5) const hyperlink_id = try page.insertHyperlink(.{ @@ -14719,8 +17494,8 @@ test "PageList split preserves hyperlinks" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no hyperlinks (the hyperlink row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count()); diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index becda78b..6c4db323 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -113,7 +113,7 @@ pub const SemanticPrompt = struct { pub const SemanticClick = union(enum) { none, - click_events, + click_events: osc.semantic_prompt.ClickEvents, cl: osc.semantic_prompt.Click, }; }; @@ -530,20 +530,30 @@ pub fn clone( } // We move the top pin back in bounds to the top row. + const node = pages.pages.first.?; break :start try pages.trackPin(.{ - .node = pages.pages.first.?, - .x = if (sel.rectangle) ordered.tl.x else 0, + .node = node, + .x = if (sel.rectangle) + @min(ordered.tl.x, node.cols() - 1) + else + 0, }); }; // If we got to this point it means that the selection is not // fully out of bounds, so we move the bottom right pin back // in bounds if it isn't already. - const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ - .node = pages.pages.last.?, - .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, - .y = pages.pages.last.?.data.size.rows - 1, - }); + const end_pin = pin_remap.get(ordered.br) orelse end: { + const node = pages.pages.last.?; + break :end try pages.trackPin(.{ + .node = node, + .x = if (sel.rectangle) + @min(ordered.br.x, node.cols() - 1) + else + node.cols() - 1, + .y = node.rows() - 1, + }); + }; break :sel .{ .bounds = .{ .tracked = .{ @@ -584,7 +594,7 @@ pub fn increaseCapacity( // capacity below it will be short the ref count on our // current style and hyperlink, so we need to init those. const new_node = try self.pages.increaseCapacity(node, adjustment); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); // Re-add the style, if the page somehow doesn't have enough // memory to add it, we emit a warning and gracefully degrade @@ -651,7 +661,7 @@ pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { assert(self.cursor.y > 0); var page_pin = self.cursor.page_pin.up(1).?; - page_pin.x = self.pages.cols - 1; + page_pin.x = page_pin.node.cols() - 1; const page_rac = page_pin.rowAndCell(); return page_rac.cell; } @@ -808,7 +818,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // so our cursor is in the correct place we just have to clear // the cells. if (self.pages.rows == 1) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -845,10 +855,9 @@ pub fn cursorDownScroll(self: *Screen) !void { // allocate, prune scrollback, whatever. _ = try self.pages.grow(); - self.cursorChangePin(new_pin: { - // We do this all in a block here because referencing this pin - // after cursorChangePin is unsafe, and we want to keep it out - // of scope. + const new_pin = new_pin: { + // Calculate this before cursorChangePin because that function may + // adjust the underlying page and invalidate references to its pin. // If our pin page change it means that the page that the pin // was on was pruned. In this case, grow() moves the pin to @@ -874,10 +883,27 @@ pub fn cursorDownScroll(self: *Screen) !void { } break :new_pin page_pin; - }); - const page_rac = self.cursor.page_pin.rowAndCell(); - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + }; + + if (self.cursor.page_pin.node == new_pin.node) { + // Scrolling normally stays within one page. Resolve the page once + // while refreshing the cursor pointers. The cursor already holds + // live row and cell pointers into this mapping, and grow does not + // compress pages, so the node must still be resident here. + self.cursorMarkDirty(); + const page = new_pin.node.pageAssumeResident(); + const page_rac = page.getRowAndCell(new_pin.x, new_pin.y); + self.cursor.page_pin.* = new_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } else { + // Crossing a page may require migrating the cursor's style and + // hyperlink references, so retain the general path here. + self.cursorChangePin(new_pin); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } // Our new row is always dirty self.cursorMarkDirty(); @@ -885,7 +911,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // Clear the new row so it gets our bg color. We only do this // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -934,8 +960,8 @@ pub fn cursorScrollAbove(self: *Screen) !void { // lot cheaper in 99% of cases. const old_pin = self.cursor.page_pin.*; - if (try self.pages.grow()) |_| { - try self.cursorScrollAboveRotate(); + if (try self.pages.grow()) |new_node| { + try self.cursorScrollAboveRotate(new_node); } else { // In this case, it means grow() didn't allocate a new page. @@ -950,11 +976,13 @@ pub fn cursorScrollAbove(self: *Screen) !void { self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; const pin = self.cursor.page_pin; - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); // Rotate the rows so that the newly created empty row is at the // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. var rows = page.rows.ptr(page.memory.ptr); + // Rotating this suffix changes which logical row its coordinates identify. + self.pages.invalidateNodeLayout(pin.node); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); // Mark the whole page as dirty. @@ -988,7 +1016,7 @@ pub fn cursorScrollAbove(self: *Screen) !void { // 1 |5E00000000| | 4 // +----------+ : // +-------------+ - try self.cursorScrollAboveRotate(); + try self.cursorScrollAboveRotate(null); } } @@ -1003,7 +1031,10 @@ pub fn cursorScrollAbove(self: *Screen) !void { } } -fn cursorScrollAboveRotate(self: *Screen) !void { +fn cursorScrollAboveRotate( + self: *Screen, + fresh_node: ?*PageList.List.Node, +) !void { self.cursorChangePin(self.cursor.page_pin.down(1).?); // Go through each of the pages following our pin, shift all rows @@ -1011,11 +1042,17 @@ fn cursorScrollAboveRotate(self: *Screen) !void { var current = self.pages.pages.last.?; while (current != self.cursor.page_pin.node) : (current = current.prev.?) { const prev = current.prev.?; - const prev_page = &prev.data; - const cur_page = ¤t.data; + const prev_page = prev.page(); + const cur_page = current.page(); const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + // A newly allocated tail has no earlier references to invalidate. + if (fresh_node == null or current != fresh_node.?) { + // Rotating this page moves every cached row coordinate down by one. + self.pages.invalidateNodeLayout(current); + } + // Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ] fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]); @@ -1033,8 +1070,10 @@ fn cursorScrollAboveRotate(self: *Screen) !void { // Our current is our cursor page, we need to rotate down from // our cursor and clear our row. assert(current == self.cursor.page_pin.node); - const cur_page = ¤t.data; + const cur_page = current.page(); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + // Rotating the cursor-page suffix changes its cached row coordinates. + self.pages.invalidateNodeLayout(current); fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]); self.clearCells( cur_page, @@ -1055,6 +1094,161 @@ fn cursorScrollAboveRotate(self: *Screen) !void { self.cursor.page_cell = page_rac.cell; } +/// Scroll a full-width scroll region that ends at the cursor row up by +/// one row. The cursor must be on the bottom row of the region. `limit` +/// is the number of rows in the region above the cursor (region height +/// minus one) and must be at least 1. +/// +/// The top row of the region is discarded (NOT moved into scrollback). +/// All other rows in the region shift up by one and the cursor row +/// becomes a blank row, filled with the current background color (like +/// other scroll operations such as deleteLines). +/// +/// The cursor stays at the same screen position (the new blank row). +/// Content outside of the region is unmodified. +/// +/// This is a very hot path for scroll region usage (e.g. a program +/// on the alt screen using DECSTBM and scrolling via LF/IND) so this +/// is optimized for the common case where the full region is within +/// a single page. +pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void { + assert(limit >= 1); + assert(self.cursor.y >= limit); + defer self.assertIntegrity(); + + const pin: *Pin = self.cursor.page_pin; + + // If the region crosses a page boundary we take a slower path. This + // is rare: it requires the active area to span multiple pages with + // the split point inside the scroll region. + if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit); + + // Fast path: the entire region is in a single page. We can clear + // the top row and rotate it down to the cursor row, updating any + // tracked pins along the way. + // + // The cursor's cached row and cell pointers refer into this mapping. + // PageList cannot compress the cursor page while those pointers are + // installed, so the node is known to be resident here. + const page: *Page = pin.node.pageAssumeResident(); + const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1]; + + // Clear the erased (top) row. + { + const row: *Row = &rows[0]; + + // Whether our blank cell is a plain zero cell. This is true + // unless the cursor has a background color set (see blankCell). + const blank_is_zero = self.cursor.style_id == style.default_id or + self.cursor.style.bg_color == .none; + + if (!row.managedMemory() and blank_is_zero) { + // Hot path: the row has no managed memory (styles, + // graphemes, hyperlinks) and our blank is zero so this is + // a straight zero fill. This is the overwhelmingly common + // case for scroll region usage. + const cells = page.getCells(row); + @memset(@as([]u64, @ptrCast(cells)), 0); + } else { + // The generic clear handles managed memory and fills the + // row with our blank cell, preserving the background color. + self.clearCells(page, row, page.getCells(row)); + } + } + + // Rotate the region rows so the now-blank top row moves to the + // bottom (the cursor row) and everything else shifts up by one. + // Rotating the region changes which logical row its coordinates identify. + self.pages.invalidateNodeLayout(pin.node); + fastmem.rotateOnce(Row, rows); + + // Mark the whole page as dirty. + // + // Technically we only need to mark the rotated rows but this is + // a hot function, so we want to minimize work. + page.dirty = true; + + // If our viewport is a pin and it's within the rotated region + // then we need to shift its cached row offset up. See + // PageList.eraseRowBounded for details; this mirrors that logic. + if (self.pages.viewport == .pin) viewport: { + if (self.pages.viewport_pin_row_offset) |*v| { + const p = self.pages.viewport_pin; + if (p.node != pin.node or + p.y < pin.y - limit or + p.y > pin.y or + p.y == 0) break :viewport; + v.* -= 1; + } + } + + // Update tracked pins within the region since their rows moved up + // by one. The cursor's own pin is skipped because the cursor stays + // at the region bottom (the new blank row). + const pin_keys = self.pages.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != pin.node or + p == pin or + p.y < pin.y - limit or + p.y > pin.y) continue; + if (p.y == 0) p.x = 0 else p.y -= 1; + } + + // The cursor pin is unchanged, but the Row structure at the pin + // position now contains the blank row, so we need to refresh our + // cached row/cell pointers. We compute them directly from the row + // slice we already have rather than going through the pin since + // this is a hot path. + const cursor_row: *Row = &rows[limit]; + self.cursor.page_row = cursor_row; + self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x]; +} + +/// Slow path for cursorScrollRegionUp: the scroll region spans +/// multiple pages so we use the generic PageList erase machinery. +fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void { + // The call to eraseRowBounded below will move our tracked cursor + // pin up by one row since it is inside the erased region, but we + // don't actually want that: the cursor stays put, on the new blank + // row at the region bottom. We keep the old pin and put it back + // after, exactly like cursorDownScroll does for the no-scrollback + // case. + // + // This matters beyond performance: when the cursor is on the first + // row of a page, eraseRowBounded moves the tracked pin to the + // previous page. Moving the cursor back down with cursorDown would + // then cross pages via cursorChangePin, which migrates the cursor + // style refcount from a page that never held it, corrupting the + // style accounting. + const old_pin = self.cursor.page_pin.*; + + try self.pages.eraseRowBounded( + .{ .active = .{ .y = @intCast(self.cursor.y - limit) } }, + limit, + ); + + // We don't use `cursorChangePin` here because we aren't actually + // changing the pin, we're keeping it the same. Since the page + // never changes, the cursor's style ref stays valid and no style + // accounting needs to be updated. + self.cursor.page_pin.* = old_pin; + + // We do, however, need to refresh the cached page row and cell, + // because the row contents at our pin position changed (it now + // contains the blank row). + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // eraseRowBounded clears the new row with zero cells so if our + // blank cell isn't zero (bg color is set) we need to fill it. + const blank = self.blankCell(); + if (!blank.isZero()) { + const cells: [*]Cell = @ptrCast(self.cursor.page_cell); + @memset((cells - self.cursor.x)[0..self.pages.cols], blank); + } +} + /// Move the cursor down if we're not at the bottom of the screen. Otherwise /// scroll. Currently only used for testing. inline fn cursorDownOrScroll(self: *Screen) !void { @@ -1107,7 +1301,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { // If the other cursor had a hyperlink, add it to ours. if (opts.hyperlink and other.hyperlink_id != 0) { // Get the hyperlink from the other cursor's page. - const other_page = &other.page_pin.node.data; + const other_page = other.page_pin.node.page(); const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id); const uri = other_link.uri.slice(other_page.memory); @@ -1162,7 +1356,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // Release the style directly from the old page instead of going through // manualStyleUpdate, because the cursor position may have already been // updated but the pin has not, which would fail integrity checks. - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.styles.release(old_page.memory, self.cursor.style_id); self.cursor.style = .{}; self.cursor.style_id = style.default_id; @@ -1170,7 +1364,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // If we have a hyperlink then we need to release it from the old page. if (self.cursor.hyperlink != null) { - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); } @@ -1245,12 +1439,12 @@ pub fn cursorResetWrap(self: *Screen) void { // If the last cell in the row is a spacer head we need to clear it. const cells = self.cursor.page_pin.cells(.all); - const cell = cells[self.cursor.page_pin.node.data.size.cols - 1]; + const cell = cells[self.cursor.page_pin.node.cols() - 1]; if (cell.wide == .spacer_head) { self.clearCells( - &self.cursor.page_pin.node.data, + self.cursor.page_pin.node.page(), page_row, - cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1], + cells[self.cursor.page_pin.node.cols() - 1 ..][0..1], ); } } @@ -1347,19 +1541,19 @@ pub fn clearRows( var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { + const page = chunk.node.page(); for (chunk.rows()) |*row| { const cells_offset = row.cells; - const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); - const cells = cells_multi[0..self.pages.cols]; + const cells = page.getCells(row); // Clear all cells if (protected) { - self.clearUnprotectedCells(&chunk.node.data, row, cells); + self.clearUnprotectedCells(page, row, cells); // We need to preserve other row attributes since we only // cleared unprotected cells. row.cells = cells_offset; } else { - self.clearCells(&chunk.node.data, row, cells); + self.clearCells(page, row, cells); row.* = .{ .cells = cells_offset }; } @@ -1377,6 +1571,8 @@ pub fn clearCells( row: *Row, cells: []Cell, ) void { + if (cells.len == 0) return; + // This whole operation does unsafe things, so we just want to assert // the end state. page.pauseIntegrityChecks(true); @@ -1408,7 +1604,7 @@ pub fn clearCells( // If we have no left/right scroll region we can be sure // that we've cleared all the graphemes, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.grapheme = false; } else { page.updateRowGraphemeFlag(row); @@ -1424,7 +1620,7 @@ pub fn clearCells( // If we have no left/right scroll region we can be sure // that we've cleared all the hyperlinks, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.hyperlink = false; } else { page.updateRowHyperlinkFlag(row); @@ -1432,15 +1628,27 @@ pub fn clearCells( } if (row.styled) { - for (cells) |*cell| { - if (cell.hasStyling()) - page.styles.release(page.memory, cell.style_id); + // Styled cells overwhelmingly come in runs sharing the same + // style (e.g. a colored status bar or a highlighted region), + // so group them and release each run with a single ref-count + // update rather than per cell. + var i: usize = 0; + while (i < cells.len) { + const id = cells[i].style_id; + if (id == style.default_id) { + i += 1; + continue; + } + var j = i + 1; + while (j < cells.len and cells[j].style_id == id) j += 1; + page.styles.releaseMultiple(page.memory, id, @intCast(j - i)); + i = j; } // If we have no left/right scroll region we can be sure // that we've cleared all the styles, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.styled = false; } else { page.updateRowStyledFlag(row); @@ -1449,7 +1657,7 @@ pub fn clearCells( if (comptime build_options.kitty_graphics) { if (row.kitty_virtual_placeholder and - cells.len == self.pages.cols) + cells.len == page.size.cols) { for (cells) |c| { if (c.codepoint() == kitty.graphics.unicode.placeholder) { @@ -1525,12 +1733,12 @@ pub fn splitCellBoundary( self: *Screen, x: size.CellCountInt, ) void { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); - const cols = self.cursor.page_pin.node.data.size.cols; + const cols = self.cursor.page_pin.node.cols(); // `x` may be up to an INCLUDING `cols`, since that signifies splitting // the boundary to the right of the final cell in the row. @@ -1574,12 +1782,12 @@ pub fn splitCellBoundary( if (self.cursor.page_pin.up(1)) |p_row| { const p_rac = p_row.rowAndCell(); const p_cells = p_row.cells(.all); - const p_cell = p_cells[p_row.node.data.size.cols - 1]; + const p_cell = p_cells[p_row.node.cols() - 1]; if (p_cell.wide == .spacer_head) { self.clearCells( - &p_row.node.data, + p_row.node.page(), p_rac.row, - p_cells[p_row.node.data.size.cols - 1 ..][0..1], + p_cells[p_row.node.cols() - 1 ..][0..1], ); } } @@ -1690,7 +1898,7 @@ pub inline fn resize( if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null; @@ -1727,7 +1935,7 @@ pub inline fn resize( // For `.last`, only clear the current line where the cursor is. // For `.true`, clear all prompt lines starting from the beginning. .last => { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); const row = self.cursor.page_row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -1753,7 +1961,7 @@ pub inline fn resize( // shell is going to expect this space to be available. var it = start.rowIterator(.right_down, null); while (it.next()) |pin| { - const page = &pin.node.data; + const page = pin.node.page(); const row = pin.rowAndCell().row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -1992,6 +2200,12 @@ pub fn setAttribute( .unknown => return, } + // If the attribute didn't change our style then we can skip the + // style update entirely: our current style ID is already correct. + // This is a common case in the wild where programs re-assert the + // same style repeatedly (e.g. per span or per line). + if (self.cursor.style.eql(old_style)) return; + try self.manualStyleUpdate(); } @@ -2011,7 +2225,7 @@ pub fn setAttribute( /// there was still no room for the new style. pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { defer self.assertIntegrity(); - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); // std.log.warn("active styles={}", .{page.styles.count()}); @@ -2062,7 +2276,7 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { }, }; - page = &node.data; + page = node.page(); break :id page.styles.add( page.memory, self.cursor.style, @@ -2110,13 +2324,13 @@ fn splitForCapacity( ) PageList.SplitError!void { // Get our capacities. We include our target row because its // capacity will be preserved. - const bytes_above = Page.layout(pin.node.data.exactRowCapacity( + const bytes_above = Page.layout(pin.node.page().exactRowCapacity( 0, pin.y + 1, )).total_size; - const bytes_below = Page.layout(pin.node.data.exactRowCapacity( + const bytes_below = Page.layout(pin.node.page().exactRowCapacity( pin.y, - pin.node.data.size.rows, + pin.node.rows(), )).total_size; // We need to track the old cursor pin because if our split @@ -2152,8 +2366,8 @@ pub fn appendGrapheme( cell: *Cell, cp: u21, ) PageList.IncreaseCapacityError!void { - defer self.cursor.page_pin.node.data.assertIntegrity(); - self.cursor.page_pin.node.data.appendGrapheme( + defer self.cursor.page_pin.node.page().assertIntegrity(); + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, cp, @@ -2184,7 +2398,7 @@ pub fn appendGrapheme( .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), }; - self.cursor.page_pin.node.data.appendGrapheme( + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, reloaded_cell, cp, @@ -2214,13 +2428,13 @@ pub fn startHyperlink( .id = if (id_) |id| .{ .explicit = id, } else implicit: { - defer self.cursor.hyperlink_implicit_id += 1; + defer self.cursor.hyperlink_implicit_id +%= 1; break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id }; }, }; errdefer switch (link.id) { .explicit => {}, - .implicit => self.cursor.hyperlink_implicit_id -= 1, + .implicit => self.cursor.hyperlink_implicit_id -%= 1, }; // Loop until we have enough page memory to add the hyperlink @@ -2261,9 +2475,6 @@ fn startHyperlinkOnce( self: *Screen, source: hyperlink.Hyperlink, ) (Allocator.Error || Page.InsertHyperlinkError)!void { - // End any prior hyperlink - self.endHyperlink(); - // Allocate our new Hyperlink entry in non-page memory. This // lets us quickly get access to URI, ID. const link = try self.alloc.create(hyperlink.Hyperlink); @@ -2271,8 +2482,12 @@ fn startHyperlinkOnce( link.* = try source.dupe(self.alloc); errdefer link.deinit(self.alloc); + // End any prior hyperlink only after duplicating the new value. The + // source slices are allowed to reference our current hyperlink. + self.endHyperlink(); + // Insert the hyperlink into page memory - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); const id: hyperlink.Id = try page.insertHyperlink(link.*); // Save it all @@ -2299,7 +2514,7 @@ pub fn endHyperlink(self: *Screen) void { // how RefCountedSet works). This causes some memory fragmentation but // is fine because if it is ever pruned the context deleted callback // will be called. - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink.?.deinit(self.alloc); self.alloc.destroy(self.cursor.hyperlink.?); @@ -2311,7 +2526,7 @@ pub fn endHyperlink(self: *Screen) void { pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { assert(self.cursor.hyperlink_id != 0); - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); if (page.setHyperlink( self.cursor.page_row, self.cursor.page_cell, @@ -2349,9 +2564,12 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { .string_bytes, ); assert(new_node == self.cursor.page_pin.node); - page = &new_node.data; + page = new_node.page(); } + // The hyperlink map is fixed-capacity, so reaching this error + // means live entries fill the usable map capacity and the page + // must grow. _ = try self.increaseCapacity( self.cursor.page_pin.node, .hyperlink_bytes, @@ -2425,8 +2643,28 @@ pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void { const tracked_sel = if (sel.tracked()) sel else try sel.track(self); errdefer if (!sel.tracked()) tracked_sel.deinit(self); - // Untrack prior selection - if (self.selection) |*old| old.deinit(self); + // Untrack prior selection pins that aren't also owned by the replacement. + // A caller may pass our current tracked selection back to us by value, so + // releasing both old pins unconditionally would leave the replacement + // pointing at freed pool entries. + if (self.selection) |old| { + const new_bounds = tracked_sel.bounds.tracked; + switch (old.bounds) { + .untracked => old.deinit(self), + .tracked => |old_bounds| { + if (old_bounds.start != new_bounds.start and + old_bounds.start != new_bounds.end) + { + self.pages.untrackPin(old_bounds.start); + } + if (old_bounds.end != new_bounds.start and + old_bounds.end != new_bounds.end) + { + self.pages.untrackPin(old_bounds.end); + } + }, + } + } self.selection = tracked_sel; self.dirty.selection = true; } @@ -2551,7 +2789,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // First, check the current row for semantic boundaries before the clicked position. if (semantic_prompt_state) |v| { const row = it_prev.rowAndCell().row; - const cells = it_prev.node.data.getCells(row); + const cells = it_prev.node.page().getCells(row); // Scan backwards from clicked position to find where our content starts for (0..opts.pin.x + 1) |i| { const x_rev = opts.pin.x - i; @@ -2579,7 +2817,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row in reverse // order since we're going up and back. - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); for (0..cells.len) |x| { const x_rev = cells.len - 1 - x; const cell = cells[x_rev]; @@ -2607,7 +2845,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); // If this is our pin row we can start from our x because // the start_pin logic already found the real start. @@ -2620,7 +2858,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // so we scan forward to find where our content ends. if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; - prev.x = p.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end_pin prev; } @@ -2636,7 +2874,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!row.wrap) { var copy = p; - copy.x = p.node.data.size.cols - 1; + copy.x = p.node.cols() - 1; break :end_pin copy; } } @@ -2826,7 +3064,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :end p; } @@ -2846,7 +3084,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :start prev; } @@ -2898,7 +3136,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { // the prompt and will trim the trailing whitespace. const start_pin = self.pages.getTopLeft(.screen); var end_pin = next.up(1) orelse return null; - end_pin.x = end_pin.node.data.size.cols - 1; + end_pin.x = end_pin.node.cols() - 1; var cell_it = end_pin.cellIterator(.left_up, start_pin); while (cell_it.next()) |p| { const cell = p.rowAndCell().cell; @@ -3027,7 +3265,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine if this row is our cursor. const is_cursor_row = row_pin.node == cursor_pin.node and @@ -3103,7 +3341,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine the length of the cells we look at in this row. const end_len: usize = end_len: { @@ -3253,7 +3491,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { break :cell cell; }; - try self.cursor.page_pin.node.data.appendGrapheme( + try self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, c, @@ -3287,7 +3525,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } @@ -3344,7 +3582,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase twice. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; @@ -3484,7 +3722,7 @@ test "Screen cursorCopy style deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Bold should create our style try s2.setAttribute(.{ .bold = {} }); @@ -3508,15 +3746,15 @@ test "Screen cursorCopy style deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3542,10 +3780,10 @@ test "Screen cursorCopy style deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3564,7 +3802,7 @@ test "Screen cursorCopy style deref new page" { try testing.expect(!s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 0), page.styles.count()); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3580,7 +3818,7 @@ test "Screen cursorCopy style copy" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 1), page.styles.count()); @@ -3595,7 +3833,7 @@ test "Screen cursorCopy hyperlink deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Create a hyperlink for the cursor. try s2.startHyperlink("https://example.com/", null); @@ -3608,6 +3846,54 @@ test "Screen cursorCopy hyperlink deref" { try testing.expect(s2.cursor.hyperlink_id == 0); } +test "Screen write regrows compacted page capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + + // Compact the active page so every managed capacity dimension is + // zero, then reload the cursor since its cached row/cell pointers + // point into the replaced page. + { + const node = (try s.pages.compact(s.cursor.page_pin.node)).?; + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); + s.cursorReload(); + } + + // Styled write: exercises the manualStyleUpdate single-retry + // path. Prior to increaseCapacity handling zero dimensions, the + // retry would fail and the style would be dropped. + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("A"); + + // Grapheme write: exercises the appendGrapheme single-retry path. + // We can't use testWriteString here because it appends graphemes + // directly on the page without the capacity retry. + try s.testWriteString("a"); + try s.appendGrapheme(s.cursorCellLeft(1), 0x0301); + + // Hyperlink: exercises the startHyperlink retry loop, which used + // to loop forever when capacity growth from zero didn't grow. + try s.startHyperlink("https://example.com/", null); + try s.testWriteString("B"); + s.endHyperlink(); + + // Verify the content landed on the page. + const page = s.cursor.page_pin.node.page(); + try testing.expect(page.styles.count() >= 1); + try testing.expect(page.hyperlink_set.count() >= 1); + try testing.expect(page.graphemeCount() >= 1); +} + test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; @@ -3619,15 +3905,15 @@ test "Screen cursorCopy hyperlink deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3653,10 +3939,10 @@ test "Screen cursorCopy hyperlink deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3675,7 +3961,7 @@ test "Screen cursorCopy hyperlink deref new page" { try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3690,12 +3976,12 @@ test "Screen cursorCopy hyperlink copy" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3715,12 +4001,12 @@ test "Screen cursorCopy hyperlink copy disabled" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3737,7 +4023,7 @@ test "Screen style basics" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3759,7 +4045,7 @@ test "Screen style reset to default" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3779,7 +4065,7 @@ test "Screen style reset with unset" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3836,7 +4122,7 @@ test "Screen clearRows active styled line" { try s.setAttribute(.{ .unset = {} }); // We should have one style - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); s.clearRows(.{ .active = .{} }, null, false); @@ -3849,6 +4135,38 @@ test "Screen clearRows active styled line" { try testing.expectEqualStrings("", str); } +test "Screen clearCells empty range" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, .default); + defer s.deinit(); + + const page = s.cursor.page_pin.node.page(); + const row = s.cursor.page_row; + const cells = page.getCells(row); + s.clearCells(page, row, cells[0..0]); +} + +test "Screen clearRows uses stored page width" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, .{ + .cols = 2, + .rows = 1, + .max_scrollback = 0, + }); + defer s.deinit(); + + try s.testWriteString("AB"); + const node = s.pages.pages.first.?; + s.pages.cols = 4; + + s.clearRows(.{ .screen = .{} }, null, false); + const cells = node.page().getCells(&node.page().rows.ptr(node.page().memory)[0]); + try testing.expectEqual(@as(usize, 2), cells.len); + for (cells) |cell| try testing.expect(cell.isEmpty()); +} + test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator; @@ -3974,6 +4292,27 @@ test "Screen eraseRows active partial" { } } +test "Screen: cursorCellEndOfPrev across mixed-width pages" { + const testing = std.testing; + var s = try init(testing.allocator, .{ + .cols = 4, + .rows = 2, + .max_scrollback = 0, + }); + defer s.deinit(); + + try s.testWriteString("ABCDE"); + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + s.cursorReload(); + const second = first.next.?; + first.page().size.cols = 2; + + try testing.expectEqual(second, s.cursor.page_pin.node); + const expected = (Pin{ .node = first, .x = 1 }).rowAndCell().cell; + try testing.expectEqual(expected, s.cursorCellEndOfPrev()); +} + test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; @@ -3982,7 +4321,7 @@ test "Screen: cursorDown across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -3992,21 +4331,21 @@ test "Screen: cursorDown across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Scroll back to the previous page s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4017,7 +4356,7 @@ test "Screen: cursorDown across pages preserves style" { // Go back down into the next page and we should have that style s.cursorDown(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4034,7 +4373,7 @@ test "Screen: cursorUp across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4044,14 +4383,14 @@ test "Screen: cursorUp across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4062,7 +4401,7 @@ test "Screen: cursorUp across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4081,7 +4420,7 @@ test "Screen: cursorAbsolute across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4091,14 +4430,14 @@ test "Screen: cursorAbsolute across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4109,7 +4448,7 @@ test "Screen: cursorAbsolute across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorAbsolute(1, 1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4136,13 +4475,13 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); // We need our page to change for this test to make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -4177,7 +4516,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { // Go back up into the start page and we should still have that style. s.cursorAbsolute(1, 1); { - const cur_page = &s.cursor.page_pin.node.data; + const cur_page = s.cursor.page_pin.node.page(); // The page we're on now should NOT equal start_page, since its // capacity should have been adjusted, which invalidates our ptr. try testing.expect(start_page != cur_page); @@ -4191,7 +4530,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { try testing.expect(styleval.flags.bold); } - s.cursor.page_pin.node.data.assertIntegrity(); + s.cursor.page_pin.node.page().assertIntegrity(); new_page.assertIntegrity(); } @@ -4295,7 +4634,7 @@ test "Screen: scrolling across pages preserves style" { defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); // Scroll down enough to go to another page const rem = start_page.capacity.rows - start_page.size.rows + 1; @@ -4306,7 +4645,7 @@ test "Screen: scrolling across pages preserves style" { // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expect(start_page != page); const styleval = page.styles.get( @@ -4571,6 +4910,223 @@ test "Screen: scrolling moves selection" { } } +test "Screen: cursorScrollRegionUp simple" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Scroll a region ending at row 2 (zero-indexed) up by one. This + // emulates a scroll region of rows 0-2 with the cursor at the + // region bottom. + s.cursorAbsolute(1, 2); + try s.cursorScrollRegionUp(2); + + // The cursor stays in place, on the new blank row. + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + // Rows in the region scrolled, rows below are unchanged, and + // nothing was moved into scrollback. + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp renews page generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + s.cursorAbsolute(0, 2); + + const node = s.cursor.page_pin.node; + const serial = node.serial; + try s.cursorScrollRegionUp(2); + + try testing.expect(!s.pages.nodeIsValid(node, serial)); +} + +test "Screen: cursorScrollRegionUp moves selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Select the second row. + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + // Our selection should've moved up with its row. + { + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp region spans pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + defer s.deinit(); + + // We need to get the cursor to a new page + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + + // At this point: + // +----------+ = PAGE 0 + // ... : : + // +-------------+ ACTIVE + // 4305 |1A00000000| | 0 + // 4306 |2B00000000| | 1 + // 4307 |3C00000000| | 2 + // +----------+ : + // +----------+ : = PAGE 1 + // 0 |4D00000000| | 3 + // :^ : : = PIN 0 + // 1 |5E00000000| | 4 + // +----------+ : + // +-------------+ + + // Move the cursor to the first row of the second page and give it + // a non-default style. This is important: it verifies that the + // cursor's style ref stays accounted on the correct page even + // though eraseRowBounded moves the cursor's tracked pin across + // the page boundary. + s.cursorAbsolute(0, 3); + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?); + try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y); + + // Scroll a region of active rows 1-3 with the cursor at the region + // bottom. The region spans the page boundary so this exercises the + // slow path. + try s.cursorScrollRegionUp(2); + + // The cursor stays in place, on the new blank row. + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } + + // Our cursor style must remain usable: write a styled cell and + // verify the style ref counting is intact on the cursor's page. + try s.testWriteString("X"); + { + const page = s.cursor.page_pin.node.page(); + const styles = page.styles.count(); + try testing.expectEqual(@as(usize, 1), styles); + } +} + +test "Screen: cursorScrollRegionUp region spans pages with background SGR" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + defer s.deinit(); + + // We need to get the cursor to a new page. See the previous test + // for a diagram of the page layout. + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + + s.cursorAbsolute(0, 3); + try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?); + try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y); + + try s.cursorScrollRegionUp(2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } + + // The new blank row must be filled with our background color. + for (0..s.pages.cols) |x| { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 3, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Screen: cursorScrollRegionUp with styled erased row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + // Write a styled row at the top so the erased row has managed + // memory that must be released. + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("1ABCD"); + try s.setAttribute(.{ .unset = {} }); + try s.testWriteString("\n2EFGH\n3IJKL"); + + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // The style should be gone from the page since the only user + // was the erased row. + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual(@as(usize, 0), page.styles.count()); +} + test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; @@ -4759,7 +5315,10 @@ test "Screen: scroll above same page" { // +----------+ : // +-------------+ + const node = s.cursor.page_pin.node; + const serial = node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(node, serial)); // +----------+ = PAGE 0 // 0 |1ABCD00000| @@ -4805,10 +5364,10 @@ test "Screen: scroll above same page but cursor on previous page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4834,7 +5393,13 @@ test "Screen: scroll above same page but cursor on previous page" { // +----------+ : // +-------------+ + const first_node = s.pages.pages.first.?; + const second_node = first_node.next.?; + const first_serial = first_node.serial; + const second_serial = second_node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(first_node, first_serial)); + try testing.expect(!s.pages.nodeIsValid(second_node, second_serial)); // +----------+ = PAGE 0 // ... : : @@ -4886,10 +5451,10 @@ test "Screen: scroll above same page but cursor on previous page last row" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 2) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4964,8 +5529,8 @@ test "Screen: scroll above same page but cursor on previous page last row" { // This catches a case of memory corruption where the cursor // is moved between pages without accounting for style refs. try s.setAttribute(.{ .reset_bg = {} }); - s.pages.pages.first.?.data.assertIntegrity(); - s.pages.pages.last.?.data.assertIntegrity(); + s.pages.pages.first.?.page().assertIntegrity(); + s.pages.pages.last.?.page().assertIntegrity(); } test "Screen: scroll above creates new page" { @@ -4976,10 +5541,10 @@ test "Screen: scroll above creates new page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4999,7 +5564,10 @@ test "Screen: scroll above creates new page" { // 4307 |3IJKL00000| | 2 // +----------+ : // +-------------+ + const node = s.pages.pages.first.?; + const serial = node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(node, serial)); // +----------+ = PAGE 0 // ... : : @@ -5048,10 +5616,10 @@ test "Screen: scroll above with cursor on non-final row" { defer s.deinit(); // Get the cursor to be 2 rows above a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); // Write 3 lines of text, forcing the last line into the first // row of a new page. Move our cursor onto the previous page. @@ -5124,10 +5692,10 @@ test "Screen: scroll above no scrollback bottom of page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5498,6 +6066,51 @@ test "Screen: clone contains subset of selection" { } } +test "Screen: clone clamps clipped selections to mixed-width pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 2 }); + try s.pages.split(.{ .node = first, .y = 1 }); + const middle = first.next.?; + const last = middle.next.?; + middle.page().size.cols = 2; + + try s.select(Selection.init( + .{ .node = first }, + .{ .node = last, .x = 3 }, + false, + )); + var linear = try s.clone( + alloc, + .{ .screen = .{} }, + .{ .screen = .{ .y = 1 } }, + ); + defer linear.deinit(); + const linear_end = linear.selection.?.end(); + _ = linear_end.rowAndCell(); + try testing.expectEqual(@as(size.CellCountInt, 1), linear_end.x); + + try s.select(Selection.init( + .{ .node = first, .x = 3 }, + .{ .node = last, .x = 3 }, + true, + )); + var rectangle = try s.clone( + alloc, + .{ .screen = .{ .y = 1 } }, + null, + ); + defer rectangle.deinit(); + const rectangle_start = rectangle.selection.?.start(); + _ = rectangle_start.rowAndCell(); + try testing.expectEqual(@as(size.CellCountInt, 1), rectangle_start.x); +} + test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator; @@ -6533,7 +7146,7 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { // That cuts the total row count down and is what stresses the viewport pin. var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -7652,6 +8265,49 @@ test "Screen: select untracked" { try testing.expectEqual(tracked, s.pages.countTrackedPins()); } +test "Screen: select replaces existing pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + const tracked = s.pages.countTrackedPins(); + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); + + // Replacing the selection must untrack the prior selection's pins + // rather than leak them. + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); +} + +test "Screen: reselecting tracked selection preserves its pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + + try s.select(s.selection.?); + try testing.expectEqual(Selection.Order.forward, s.selection.?.order(&s)); +} + test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; @@ -8342,6 +8998,37 @@ test "Screen: selectLine semantic boundary first cell of row" { } } +test "Screen: selectLine semantic boundary across mixed-width pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("ABCD"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("E"); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + const second = first.next.?; + first.page().size.cols = 2; + s.pages.pauseIntegrityChecks(true); + defer s.pages.pauseIntegrityChecks(false); + + try testing.expectEqual(@as(size.CellCountInt, 2), first.cols()); + try testing.expectEqual(@as(size.CellCountInt, 4), second.cols()); + + var sel = s.selectLine(.{ .pin = .{ + .node = first, + .x = 1, + } }).?; + defer sel.deinit(&s); + try testing.expect((Pin{ .node = first, .x = 0 }).eql(sel.start())); + try testing.expect((Pin{ .node = first, .x = 1 }).eql(sel.end())); +} + test "Screen: selectLine semantic all same content" { const testing = std.testing; const alloc = testing.allocator; @@ -9141,7 +9828,7 @@ test "Screen: selectionString with zero width joiner" { const cell = pin.rowAndCell().cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = pin.node.data.lookupGrapheme(cell).?; + const cps = pin.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -9276,14 +9963,14 @@ test "Screen: selectionString multi-page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; + const first_page_size = s.pages.pages.first.?.capacity().rows; // Lazy way to seek to the first page boundary. - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 1) |_| { try s.testWriteString("\n"); } - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.testWriteString("123456789\n!@#$%^&*(\n123456789"); @@ -9374,25 +10061,77 @@ test "Screen: hyperlink start/end" { defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } +test "Screen: hyperlink accepts its current values" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + try s.startHyperlink("http://example.com", "current"); + const current = s.cursor.hyperlink.?; + try s.startHyperlink(current.uri, current.id.explicit); + + try testing.expectEqualStrings("http://example.com", s.cursor.hyperlink.?.uri); + try testing.expectEqualStrings("current", s.cursor.hyperlink.?.id.explicit); +} + +test "Screen: implicit hyperlink ID wraps" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt); + try s.startHyperlink("http://example.com", null); + + try testing.expectEqual(@as(size.OffsetInt, 0), s.cursor.hyperlink_implicit_id); + try testing.expectEqual( + std.math.maxInt(size.OffsetInt), + s.cursor.hyperlink.?.id.implicit, + ); + + // A failed allocation must roll the wrapped counter back to its + // original value as well. + s.endHyperlink(); + s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt); + var failing = testing.FailingAllocator.init(alloc, .{}); + failing.fail_index = failing.alloc_index; + { + const original_alloc = s.alloc; + defer s.alloc = original_alloc; + s.alloc = failing.allocator(); + try testing.expectError( + error.OutOfMemory, + s.startHyperlink("http://example.com", null), + ); + } + try testing.expectEqual( + std.math.maxInt(size.OffsetInt), + s.cursor.hyperlink_implicit_id, + ); +} + test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator; @@ -9402,7 +10141,7 @@ test "Screen: hyperlink reuse" { try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } @@ -9415,14 +10154,14 @@ test "Screen: hyperlink reuse" { try s.startHyperlink("http://example.com", null); try testing.expectEqual(id, s.cursor.hyperlink_id); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9442,7 +10181,7 @@ test "Screen: hyperlink cursor state on resize" { try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } @@ -9450,14 +10189,14 @@ test "Screen: hyperlink cursor state on resize" { try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9478,8 +10217,8 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { // Figure out how many cells should can have hyperlinks in this page, // and write twice that number, to guarantee the capacity needs to be // increased at some point. - const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity(); - const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes; + const base_capacity = s.cursor.page_pin.node.page().hyperlinkCapacity(); + const base_string_bytes = s.cursor.page_pin.node.capacity().string_bytes; for (0..base_capacity * 2) |_| { try s.cursorSetHyperlink(); if (s.cursor.x >= s.pages.cols - 1) { @@ -9491,9 +10230,9 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { } // Make sure the capacity really did increase. - try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity()); + try testing.expect(base_capacity < s.cursor.page_pin.node.page().hyperlinkCapacity()); // And that our string_bytes increased as well. - try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes); + try testing.expect(base_string_bytes < s.cursor.page_pin.node.capacity().string_bytes); } test "Screen: increaseCapacity cursor style ref count preserved" { @@ -9516,7 +10255,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs try testing.expectEqual( 6, @@ -9545,7 +10284,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { // After increaseCapacity, the 5 chars are cloned (5 refs) and // the cursor's style is re-added (1 ref) = 6 total. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); } @@ -9569,7 +10308,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // Cursor has the hyperlink active = 1 count in hyperlink_set try testing.expectEqual(1, page.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); @@ -9589,7 +10328,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { // After increaseCapacity, the hyperlink is re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -9619,7 +10358,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs for bold style try testing.expectEqual( 6, @@ -9649,7 +10388,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { // After increaseCapacity, both style and hyperlink are re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); try testing.expectEqual(1, page.hyperlink_set.count()); @@ -9688,11 +10427,11 @@ test "Screen: increaseCapacity non-cursor page returns early" { // Grow pages until we have multiple pages. The cursor's pin stays on // the first page since we're just adding rows. const first_page_node = s.pages.pages.first.?; - first_page_node.data.pauseIntegrityChecks(true); - for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| { + first_page_node.page().pauseIntegrityChecks(true); + for (0..first_page_node.capacity().rows - first_page_node.rows()) |_| { _ = try s.pages.grow(); } - first_page_node.data.pauseIntegrityChecks(false); + first_page_node.page().pauseIntegrityChecks(false); _ = try s.pages.grow(); // Now we have two pages @@ -9703,8 +10442,8 @@ test "Screen: increaseCapacity non-cursor page returns early" { try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.cursor.page_pin.node != second_page); - const second_page_styles_cap = second_page.data.capacity.styles; - const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles; + const second_page_styles_cap = second_page.capacity().styles; + const cursor_page_styles_cap = s.cursor.page_pin.node.capacity().styles; // Call increaseCapacity on the second page (NOT the cursor's page) const new_second_page = try s.increaseCapacity(second_page, .styles); @@ -9712,13 +10451,13 @@ test "Screen: increaseCapacity non-cursor page returns early" { // The second page should have increased capacity try testing.expectEqual( second_page_styles_cap * 2, - new_second_page.data.capacity.styles, + new_second_page.capacity().styles, ); // The cursor's page (first page) should be unchanged try testing.expectEqual( cursor_page_styles_cap, - s.cursor.page_pin.node.data.capacity.styles, + s.cursor.page_pin.node.capacity().styles, ); // Cursor state should be completely unchanged since we didn't touch its page @@ -9739,9 +10478,9 @@ test "Screen: cursorDown to page with insufficient capacity" { // cursor movement functions). The bug pattern: // // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) - // 2. cursorChangePin is called, which may trigger adjustCapacity + // 2. cursorChangePin is called, which may trigger increaseCapacity // if the target page's style map is full - // 3. adjustCapacity frees the old page and creates a new one + // 3. increaseCapacity frees the old page and creates a new one // 4. The local pin copy still points to the freed page // 5. rowAndCell() on the stale pin accesses freed memory @@ -9753,18 +10492,18 @@ test "Screen: cursorDown to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to create a second page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); // Cursor should now be on a new page - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); try testing.expect(start_page != new_page); // Fill new_page's style map to capacity. When we move INTO this page - // with a style set, adjustCapacity will be triggered. + // with a style set, increaseCapacity will be triggered. { new_page.pauseIntegrityChecks(true); defer new_page.pauseIntegrityChecks(false); @@ -9790,10 +10529,10 @@ test "Screen: cursorDown to page with insufficient capacity" { if (s.cursor.page_pin.down(1)) |next_pin| { if (next_pin.node != cur_node) { // Cursor is at 'row', moving down crosses to new_page - try testing.expect(&next_pin.node.data == new_page); + try testing.expect(next_pin.node.page() == new_page); // This cursorDown triggers the bug: the local page_pin copy - // becomes stale after adjustCapacity, causing rowAndCell() + // becomes stale after increaseCapacity, causing rowAndCell() // to access freed memory. s.cursorDown(1); @@ -9825,7 +10564,7 @@ test "Screen setAttribute increases capacity when style map is full" { try s.testWriteString("line1\nline2\nline3\nline4\nline5"); // Get the page and fill its style map to capacity - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const original_styles_capacity = page.capacity.styles; // Fill the style map to capacity using the StyleSet's layout capacity @@ -9856,7 +10595,7 @@ test "Screen setAttribute increases capacity when style map is full" { try testing.expect(s.cursor.style_id != style.default_id); // Either the capacity increased or the page was split/changed - const current_page = &s.cursor.page_pin.node.data; + const current_page = s.cursor.page_pin.node.page(); const capacity_increased = current_page.capacity.styles > original_styles_capacity; const page_changed = current_page != page; try testing.expect(capacity_increased or page_changed); @@ -9882,7 +10621,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { // Increase the page's style capacity to max by repeatedly calling increaseCapacity // Use Screen.increaseCapacity to properly maintain cursor state const max_styles = std.math.maxInt(size.CellCountInt); - while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (s.cursor.page_pin.node.capacity().styles < max_styles) { _ = s.increaseCapacity( s.cursor.page_pin.node, .styles, @@ -9890,7 +10629,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { } // Get the page reference after increaseCapacity - cursor may have moved - var page = &s.cursor.page_pin.node.data; + var page = s.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill the style map to capacity using the StyleSet's layout capacity diff --git a/vendor/libghostty-vt/src/terminal/Selection.zig b/vendor/libghostty-vt/src/terminal/Selection.zig index 5258210c..be6854e5 100644 --- a/vendor/libghostty-vt/src/terminal/Selection.zig +++ b/vendor/libghostty-vt/src/terminal/Selection.zig @@ -155,12 +155,12 @@ pub fn topLeft(self: Selection, s: *const Screen) Pin { .reverse => self.end(), .mirrored_forward => pin: { var p = self.start(); - p.x = self.end().x; + p.x = @min(self.end().x, p.node.cols() - 1); break :pin p; }, .mirrored_reverse => pin: { var p = self.end(); - p.x = self.start().x; + p.x = @min(self.start().x, p.node.cols() - 1); break :pin p; }, }; @@ -173,12 +173,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin { .reverse => self.start(), .mirrored_forward => pin: { var p = self.end(); - p.x = self.start().x; + p.x = @min(self.start().x, p.node.cols() - 1); break :pin p; }, .mirrored_reverse => pin: { var p = self.start(); - p.x = self.end().x; + p.x = @min(self.end().x, p.node.cols() - 1); break :pin p; }, }; @@ -326,6 +326,7 @@ pub fn containedRowCached( br: point.Coordinate, p: point.Coordinate, ) ?Selection { + _ = s; if (p.y < tl.y or p.y > br.y) return null; // Rectangle case: we can return early as the x range will always be the @@ -333,12 +334,12 @@ pub fn containedRowCached( if (self.rectangle) return init( start: { var copy: Pin = pin; - copy.x = tl.x; + copy.x = @min(tl.x, copy.node.cols() - 1); break :start copy; }, end: { var copy: Pin = pin; - copy.x = br.x; + copy.x = @min(br.x, copy.node.cols() - 1); break :end copy; }, true, @@ -355,7 +356,7 @@ pub fn containedRowCached( tl_pin, end: { var copy: Pin = pin; - copy.x = s.pages.cols - 1; + copy.x = copy.node.cols() - 1; break :end copy; }, false, @@ -387,7 +388,7 @@ pub fn containedRowCached( }, end: { var copy: Pin = pin; - copy.x = s.pages.cols - 1; + copy.x = copy.node.cols() - 1; break :end copy; }, false, @@ -432,7 +433,7 @@ pub fn adjust( var current = end_pin.*; while (current.down(1)) |next| : (current = next) { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; break; @@ -494,7 +495,7 @@ pub fn adjust( ); while (it.next()) |next| { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; end_pin.x = @intCast(cells.len - 1); @@ -505,7 +506,7 @@ pub fn adjust( .beginning_of_line => end_pin.x = 0, - .end_of_line => end_pin.x = end_pin.node.data.size.cols - 1, + .end_of_line => end_pin.x = end_pin.node.cols() - 1, } } @@ -1054,6 +1055,32 @@ test "Selection: order, standard" { } } +test "Selection: rectangle corners clamp across mixed-width pages" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ + .cols = 4, + .rows = 2, + .max_scrollback = 0, + }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + const second = first.next.?; + second.page().size.cols = 2; + + const sel = Selection.init( + .{ .node = first, .x = 3 }, + .{ .node = second, .x = 1 }, + true, + ); + try testing.expectEqual(.mirrored_forward, sel.order(&s)); + + const bottom_right = sel.bottomRight(&s); + _ = bottom_right.rowAndCell(); + try testing.expect((Pin{ .node = second, .x = 1 }).eql(bottom_right)); +} + test "Selection: order, rectangle" { const testing = std.testing; const alloc = testing.allocator; @@ -1602,3 +1629,46 @@ test "Selection: containedRow" { ).?); } } + +test "Selection: containedRow clamps mixed-width pages" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ + .cols = 4, + .rows = 3, + .max_scrollback = 0, + }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 2 }); + try s.pages.split(.{ .node = first, .y = 1 }); + const middle = first.next.?; + const last = middle.next.?; + middle.page().size.cols = 2; + + const linear = Selection.init( + .{ .node = first, .x = 1 }, + .{ .node = last, .x = 1 }, + false, + ); + const linear_row = linear.containedRow( + &s, + .{ .node = middle }, + ).?; + _ = linear_row.end().rowAndCell(); + try testing.expect((Pin{ .node = middle }).eql(linear_row.start())); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(linear_row.end())); + + const rectangle = Selection.init( + .{ .node = first, .x = 1 }, + .{ .node = last, .x = 3 }, + true, + ); + const rectangle_row = rectangle.containedRow( + &s, + .{ .node = middle }, + ).?; + _ = rectangle_row.end().rowAndCell(); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.start())); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.end())); +} diff --git a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig index f0e11f63..2be1e8aa 100644 --- a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig +++ b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig @@ -228,7 +228,8 @@ pub fn validatedLeftClickPin( } pub const Press = struct { - /// The time when the press event occurred. Use a monotonic timer. + /// The time when the press event occurred. Prefer a monotonic timer; + /// backwards timestamps reset the repeat sequence. /// This can be null if you're on a system that doesn't support /// time for some reason. In that case, we only support single clicks. time: ?Time, @@ -387,8 +388,8 @@ pub fn drag( .cell => dragSelection( click_pin.*, d.pin, - @intFromFloat(@max(0, self.left_click_xpos)), - @intFromFloat(@max(0, d.xpos)), + pixelFromFloat(self.left_click_xpos), + pixelFromFloat(d.xpos), d.rectangle, d.geometry, ), @@ -630,7 +631,8 @@ fn pressRepeat( const time = p.time orelse return error.PressRequiresReset; { const prev_time = self.left_click_time orelse return error.PressRequiresReset; - const since = timeSince(time, prev_time); + const since = timeSince(time, prev_time) orelse + return error.PressRequiresReset; if (since > p.repeat_interval) return error.PressRequiresReset; } @@ -664,11 +666,29 @@ fn pressRepeat( self.left_click_behavior = p.behaviors[self.left_click_count - 1]; } -fn timeSince(time: Time, prev_time: Time) u64 { - if (comptime freestanding_wasm) return time -| prev_time; +fn timeSince(time: Time, prev_time: Time) ?u64 { + if (comptime freestanding_wasm) { + if (time < prev_time) return null; + return time - prev_time; + } + + if (time.order(prev_time) == .lt) return null; return time.since(prev_time); } +/// Convert a caller-provided floating-point position to a pixel coordinate. +/// Negative and NaN values clamp to the origin, matching the drag behavior +/// outside the left edge of the surface. +fn pixelFromFloat(value: f64) u32 { + if (std.math.isNan(value) or value <= 0) return 0; + + // @intFromFloat requires a value representable by the destination type. + // Saturate first so positive infinity and oversized coordinates are safe. + const max: f64 = @floatFromInt(std.math.maxInt(u32)); + if (value >= max) return std.math.maxInt(u32); + return @intFromFloat(value); +} + fn pressSelection( self: *const SelectionGesture, screen: *Screen, @@ -713,6 +733,8 @@ fn dragSelection( // Rectangular selections are handled similarly, except that // entire columns are considered rather than individual cells. + if (geometry.columns == 0 or geometry.cell_width == 0) return null; + // We only include cells in the selection if the threshold point lies // between the start and end points of the selection. A threshold of // 60% of the cell width was chosen empirically because it felt good. @@ -721,7 +743,12 @@ fn dragSelection( )); // We use this to clamp the pixel positions below. - const max_x = geometry.columns * geometry.cell_width - 1; + const pixel_span = std.math.mul( + u32, + geometry.columns, + geometry.cell_width, + ) catch std.math.maxInt(u32); + const max_x = pixel_span - 1; // We need to know how far across in the cell the drag pos is, so // we subtract the padding and then take it modulo the cell width. @@ -935,6 +962,16 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { }; } +fn testInstant(ns: u64) std.time.Instant { + return switch (builtin.os.tag) { + .windows, .uefi, .wasi => .{ .timestamp = ns }, + else => .{ .timestamp = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + } }, + }; +} + fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { return .{ .pin = t.screens.active.pages.pin(.{ .active = .{ @@ -1532,6 +1569,58 @@ test "SelectionGesture drag returns selection and records autoscroll" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture drag clamps unrepresentable positions" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + const positive = gesture.drag( + &t, + testDrag(&t, 1, 1, std.math.inf(f64), 50), + ).?; + try testing.expect((testPin(&t, 1, 1)).eql(positive.start())); + try testing.expect((testPin(&t, 1, 1)).eql(positive.end())); + + try testing.expectEqual(null, gesture.drag( + &t, + testDrag(&t, 1, 1, std.math.nan(f64), 50), + )); +} + +test "SelectionGesture drag saturates overflowing geometry" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + var drag_event = testDrag(&t, 1, 1, 10, 50); + drag_event.geometry.columns = std.math.maxInt(u32); + drag_event.geometry.cell_width = std.math.maxInt(u32); + try testing.expectEqual(null, gesture.drag(&t, drag_event)); +} + +test "SelectionGesture drag rejects empty geometry" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + var drag_event = testDrag(&t, 1, 1, 10, 50); + drag_event.geometry.columns = 0; + drag_event.geometry.cell_width = 0; + try testing.expectEqual(null, gesture.drag(&t, drag_event)); +} + test "SelectionGesture release clears autoscroll and records drag" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -1965,6 +2054,22 @@ test "SelectionGesture expired repeat resets click count" { try testing.expectEqual(@as(u3, 1), gesture.left_click_count); } +test "SelectionGesture backwards repeat time resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const earlier = testInstant(std.time.ns_per_s); + const later = testInstant(2 * std.time.ns_per_s); + _ = try gesture.press(&t, testPress(&t, 1, 1, later)); + _ = try gesture.press(&t, testPress(&t, 1, 1, earlier)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.eq, gesture.left_click_time.?.order(earlier)); +} + test "SelectionGesture screen switch resets click count" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); diff --git a/vendor/libghostty-vt/src/terminal/Tabstops.zig b/vendor/libghostty-vt/src/terminal/Tabstops.zig index 68138cbf..8dbe6789 100644 --- a/vendor/libghostty-vt/src/terminal/Tabstops.zig +++ b/vendor/libghostty-vt/src/terminal/Tabstops.zig @@ -170,11 +170,11 @@ pub fn reset(self: *Tabstops, interval: usize) void { @memset(&self.prealloc_stops, 0); @memset(self.dynamic_stops, 0); - if (interval > 0) { - var i: usize = interval; - while (i < self.cols - 1) : (i += interval) { - self.set(i); - } + if (interval == 0 or self.cols <= 1) return; + + var i: usize = interval; + while (i < self.cols - 1) : (i += interval) { + self.set(i); } } @@ -230,6 +230,13 @@ test "Tabstops: interval" { try testing.expect(t.get(8)); } +test "Tabstops: interval with zero columns" { + var t: Tabstops = try init(testing.allocator, 0, 8); + defer t.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), t.cols); +} + test "Tabstops: count on 80" { // https://superuser.com/questions/710019/why-there-are-11-tabstops-on-a-80-column-console diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig index d41e4750..08920127 100644 --- a/vendor/libghostty-vt/src/terminal/Terminal.zig +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -17,6 +17,7 @@ const modespkg = @import("modes.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); +const glyph = @import("apc/glyph.zig"); const kitty = @import("kitty.zig"); const osc = @import("osc.zig"); const point = @import("point.zig"); @@ -29,6 +30,7 @@ const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Page = pagepkg.Page; @@ -81,6 +83,9 @@ modes: modespkg.ModeState = .{}, /// The most recently set mouse shape for the terminal. mouse_shape: mouse.Shape = .text, +/// Per-session Glyph Protocol registrations. +glyph_glossary: glyph.Glossary = .empty, + /// These are just a packed set of flags we may set on the terminal. flags: packed struct { // This supports a Kitty extension where programs using semantic @@ -146,6 +151,49 @@ pub const Colors = struct { }; }; +/// Returns the current color for an xterm OSC color target. +/// +/// Unsupported dynamic and special colors return null. The cursor color +/// follows xterm-style reporting and falls back to the foreground color when +/// no explicit cursor color is set. +pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB { + return switch (target) { + .palette => |i| self.colors.palette.current[i], + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get() orelse + self.colors.foreground.get(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => null, + }, + .special => null, + }; +} + +/// Returns the current color for a Kitty color protocol key. +/// +/// Only palette, foreground, background, and cursor colors are backed by +/// Terminal state. Unsupported keys, or supported dynamic colors without a +/// value, return null. +pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB { + return switch (key) { + .palette => |palette| self.colors.palette.current[palette], + .special => |special| switch (special) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get(), + else => null, + }, + }; +} + /// This is a set of dirty flags the renderer can use to determine /// what parts of the screen need to be redrawn. It is up to the renderer /// to clear these flags. @@ -165,6 +213,11 @@ pub const Dirty = packed struct { /// Set when the pre-edit is modified. preedit: bool = false, + + /// Set when Glyph Protocol registrations may have changed. Registered + /// glyphs can affect already-visible PUA cells, so this requires a full + /// render-state rebuild. + glyph_glossary: bool = false, }; /// Scrolling region is the area of the screen designated where scrolling @@ -256,6 +309,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.screens.deinit(alloc); self.pwd.deinit(alloc); self.title.deinit(alloc); + self.glyph_glossary.deinit(alloc); self.* = undefined; } @@ -311,6 +365,520 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } } +/// Print multiple codepoints to the terminal at once. This is +/// semantically identical to calling `print` for each codepoint in +/// order, but is much faster because it can batch cell writes and +/// hoist per-codepoint checks out of the hot loop. +/// +/// The codepoints must all be printable: it is illegal for any +/// codepoint in this slice to be a C0 control character. Therefore, +/// this should only be called as a result of a proper VT parser +/// (like our own). +/// +/// This is optimized for the common case: ASCII, soft-wrap, etc. +/// Sequences of codepoints that require special handling (e.g. wide characters, +/// grapheme clustering) are handled correctly but fall back to the +/// slower per-codepoint path. They're less common and this is optimized +/// for the aforementioned cases. +pub fn printSlice(self: *Terminal, cps: []const u32) !void { + var i: usize = 0; + while (i < cps.len) { + // Try the fast-path print first. This will return the number of + // codepoints it consumed. + const consumed = try self.printSliceFast(cps[i..]); + if (consumed > 0) { + i += consumed; + continue; + } + + // Consuming zero bytes means that the fast path can't handle + // the next codepoint or the terminal is in a state we can't + // fast-path. Fall back to the slow cp-by-cp print then try + // fast paths again. + try self.print(@intCast(cps[i])); + i += 1; + } +} + +/// Attempt to print a prefix of `cps` using a batched fast path that +/// writes cells directly. Returns the number of codepoints consumed. +/// A return value of zero means the caller must print the first +/// codepoint via the normal `print` path. +/// +/// The fast path handles runs of narrow (width 1) and wide (width 2) +/// codepoints being written to simple cells. Everything else (zero +/// width codepoints, grapheme cluster continuations, insert mode, +/// charset mapping, hyperlinks, complex cells, etc.) is rejected so +/// `print` can handle it with full generality. +fn printSliceFast(self: *Terminal, cps: []const u32) !usize { + // Only the main display is supported. + if (self.status_display != .main) return 0; + + // Modes that require per-codepoint handling in print(). Wraparound + // is required (its the default) so that our row-fill logic below can + // assume soft-wrap semantics. Insert mode shifts cells per print. + if (self.modes.get(.insert)) return 0; + if (!self.modes.get(.wraparound)) return 0; + + const screen: *Screen = self.screens.active; + + // Charset must map ASCII as-is (true unless a DEC special charset + // is actively invoked, which is rare). + if (screen.charset.single_shift != null) return 0; + switch (screen.charset.charsets.get(screen.charset.gl)) { + .utf8, .ascii => {}, + else => return 0, + } + + // Hyperlinks require per-cell map bookkeeping. + if (screen.cursor.hyperlink_id != 0) return 0; + + // Codepoints in [0x10, 0xFF] are always narrow (width 1, matching + // the c <= 0xFF fast path in print) and can never interact with + // grapheme clustering (which requires a codepoint > 0xFF). + // + // Codepoints above 0xFF are batchable if their width is 1 or 2 + // (excluding zero-width characters such as combining marks, ZWJ, + // and variation selectors) and, when grapheme clustering (mode + // 2027) is enabled, if they are a grapheme break from the + // previously printed codepoint (so print would never attach them + // to the previous cell). + const grapheme_cluster = self.modes.get(.grapheme_cluster); + + // When grapheme clustering is enabled and a left margin is set, + // print() consults the cell left of the margin after wrapping, + // which we can't reason about here. Restrict the fast path to + // the [0x10, 0xFF] range in that case (those never cluster). + const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0; + + // Codepoints in [0x10, 0xFF] are always narrow: print() + // hardcodes width 1 for c <= 0xFF (no width table lookup). + // They also can never interact with grapheme clustering, + // which print() only performs for c > 0xFF, so they're + // immediately eligible for the narrow fill with no further + // checks. + const cp0 = cps[0]; + if (cp0 <= 0xFF) { + // C0 control characters (0x00-0x0F) aren't printable. The + // stream never sends these (they're routed to execute), but + // printSlice is a public API so defer to print() for safety. + if (cp0 < 0x10) return 0; + return self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ); + } + + if (!allow_unicode) return 0; + if (comptime build_options.kitty_graphics) { + // The Kitty graphics placeholder requires row bookkeeping. + if (cp0 == kitty.graphics.unicode.placeholder) return 0; + } + + // The first codepoint requires care when grapheme clustering is + // enabled: print() examines the previous *cell* which can hold + // state (grapheme data) that we can't cheaply reason about here. + // Note this includes the pending-wrap state: print() may attach + // to the pending cell *instead of wrapping*. We only take the + // first codepoint if the cursor is at column zero with no pending + // wrap, where print() skips clustering entirely. + if (grapheme_cluster) { + if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0; + } + + // The width lookup is a runtime value while printSliceFill is + // specialized at comptime by width class, so this switch selects + // between the two instantiations rather than passing the width + // through as an argument. + return switch (unicode.table.get(@intCast(cp0)).width) { + 1 => self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ), + 2 => self.printSliceFill( + .wide, + cps, + grapheme_cluster, + allow_unicode, + ), + else => 0, + }; +} + +/// The width class of a printSlice batch. Each batch contains only +/// codepoints of a single width class because they fill cells +/// differently: wide codepoints occupy a (wide, spacer_tail) cell +/// pair while narrow codepoints occupy a single cell. +const PrintSliceWidth = enum(u1) { + narrow, + wide, + + /// The number of cells each codepoint of this width class occupies. + fn cellsPerCp(comptime self: PrintSliceWidth) usize { + return switch (self) { + .narrow => 1, + .wide => 2, + }; + } +}; + +/// Whether a codepoint above 0xFF is eligible for the batched print +/// fast path with the given width class. +inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool { + assert(cp > 0xFF); + if (comptime build_options.kitty_graphics) { + if (cp == kitty.graphics.unicode.placeholder) return false; + } + + return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) { + .narrow => 1, + .wide => 2, + }); +} + +/// The row-filling portion of the printSlice fast path, specialized by +/// width class. The first codepoint must already be validated by the +/// caller (printSliceFast). +fn printSliceFill( + self: *Terminal, + comptime width: PrintSliceWidth, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { + const screen: *Screen = self.screens.active; + + // Our fast path can only handle "simple" cells. A simple cell is + // a codepoint cell (no grapheme data or bg-color tag), narrow, and + // not a hyperlink. The mask covers every field that must match + // the expected value (see printSliceCheckExpected) exactly. + const SimpleMask = pagepkg.Mask(Cell, &.{ + "content_tag", + "style_id", + "wide", + "hyperlink", + }, 4); + + // The bit offset of the codepoint content within a Cell, used to + // construct cell values from a template without field assignments. + const cp_shift = @bitOffsetOf(Cell, "content"); + + // Determine the run of codepoints in the same width class that we + // can batch. For codepoints after the first, the previous codepoint + // in the run is always written as a fresh, single-codepoint cell, + // so the grapheme break check against it is exact. + const run_len: usize = run: { + var idx: usize = 1; + + // Vectorized scan for the narrow class: codepoints in + // [0x10, 0xFF] are always eligible with no further checks + // and dominate real-world input, so scan for the first + // codepoint outside that range several lanes at a time. + // Anything else (including eligible unicode) proceeds via + // the scalar loop below. + if (comptime width == .narrow) { + const lanes = 8; + const V = @Vector(lanes, u32); + const lo: V = @splat(0x10); + const hi: V = @splat(0xFF); + while (idx + lanes <= cps.len) { + const v: V = cps[idx..][0..lanes].*; + const in_range = (v >= lo) & (v <= hi); + if (!@reduce(.And, in_range)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(in_range); + idx += @ctz(~bits); + break; + } + idx += lanes; + } + } + + while (idx < cps.len) : (idx += 1) { + const cp = cps[idx]; + if (comptime width == .narrow) { + if (cp >= 0x10 and cp <= 0xFF) continue; + } + if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) { + if (!grapheme_cluster) continue; + var state: uucode.grapheme.BreakState = .default; + if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue; + } + break :run idx; + } + break :run cps.len; + }; + assert(run_len > 0); + + // After doing any printing, wrapping, scrolling, etc. we want to + // ensure that our screen remains in a consistent state. + defer screen.assertIntegrity(); + + // The number of cells each codepoint occupies. + const cells_per_cp: usize = comptime width.cellsPerCp(); + + var printed: usize = 0; + outer: while (printed < run_len) { + // If we're soft-wrapping, handle that first so that our cursor + // is in the row/column that will receive the next codepoint. + if (screen.cursor.pending_wrap) try self.printWrap(); + + // Our right margin depends on where our cursor is now, + // matching the logic in print(). + const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // A degenerate 1-wide region can't hold a wide char; print() + // has special handling so fall back to it. + if (comptime width == .wide) { + if (right_limit - self.scrolling_region.left <= 1) break; + } + + const cursor = &screen.cursor; + const avail: usize = right_limit - cursor.x; + assert(avail > 0); + + // The cursor caches live row and cell pointers into this mapping, so + // its page cannot be compressed while this print path is active. + const page = cursor.page_pin.node.pageAssumeResident(); + const cells: [*]Cell = @ptrCast(cursor.page_cell); + const style_id = cursor.style_id; + const template: Cell = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .style_id = style_id, + .wide = .narrow, + .protected = cursor.protected, + .semantic_content = cursor.semantic_content, + }; + const template_bits: u64 = @bitCast(template); + const check_expected: u64 = printSliceCheckExpected(style_id); + + if (comptime width == .wide) { + if (avail == 1) { + // Only one cell left in the row: print() writes a + // spacer head (or a blank narrow cell if we're inside + // a right margin) and wraps. We require a simple cell, + // otherwise fall back to print() for the cleanup. + if (!SimpleMask.eqlScalar(cells[0], check_expected)) break; + + var spacer = template; + if (right_limit == self.cols) { + cursor.page_row.wrap = true; + spacer.wide = .spacer_head; + } + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + cells[0] = spacer; + try self.printWrap(); + continue :outer; + } + } + + // Number of codepoints and cells we're writing to this row. + const count = @min(avail / cells_per_cp, run_len - printed); + assert(count > 0); + const cell_count = count * cells_per_cp; + + // Wide cells always come in (wide, spacer_tail) pairs. + const spacer_bits: u64 = if (comptime width == .wide) spacer: { + var spacer = template; + spacer.wide = .spacer_tail; + break :spacer @bitCast(spacer); + } else undefined; + const wide_bits: u64 = if (comptime width == .wide) wb: { + var w = template; + w.wide = .wide; + break :wb @bitCast(w); + } else undefined; + + var k: usize = 0; // cells written + fill: while (k < cell_count) { + // Find the run of simple cells so the store loop below is + // branch-free (and vectorizable). This is an early-exit + // search loop that LLVM won't auto-vectorize, and reused + // rows typically match the whole way through, so scan + // several cells at a time manually. + var simple = k; + simple: { + while (simple + SimpleMask.group_len <= cell_count) { + const p = SimpleMask.eqlPrefix( + cells[0..cell_count], + simple, + check_expected, + ); + simple += p; + if (p != SimpleMask.group_len) break :simple; + } + while (simple < cell_count) : (simple += 1) { + if (!SimpleMask.eqlScalar( + cells[simple], + check_expected, + )) break; + } + } + + if (comptime width == .wide) { + // We can only write whole (wide, spacer) pairs. + const pair_end = k + (simple - k) / 2 * 2; + var idx = k; + while (idx < pair_end) : (idx += 2) { + cells[idx] = @bitCast( + wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift), + ); + cells[idx + 1] = @bitCast(spacer_bits); + } + // If the simple run ended mid-pair we stop at the pair + // boundary and handle the offending cell below. + k = pair_end; + if (simple != pair_end) { + // The first cell of the next pair is simple but the + // second isn't; handle both via the general path. + simple = pair_end; + } + } else { + for (k..simple) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = simple; + } + if (k >= cell_count) break; + + // Bulk path for runs of cells that differ from the + // expected simple cell only by their style: this is the + // common case when styled text overwrites previously + // styled (or default-styled) rows, e.g. TUI redraws. + // These runs are handled wholesale: one scan to find the + // run of identical old styles, two ref-count updates, + // and a branch-free fill. + if (comptime width == .narrow) bulk: { + const first = SimpleMask.pattern(cells[k]); + + // The old cell must be a plain narrow codepoint cell + // with no hyperlink whose only difference is the + // style id (see printSliceCheckExpected: every other + // masked field must be zero). + const style_shift = @bitOffsetOf(Cell, "style_id"); + const old_style: style.Id = @truncate(first >> style_shift); + if (first != printSliceCheckExpected(old_style)) break :bulk; + assert(old_style != style_id); // it failed the simple check + + // Find the run of cells with identical masked bits. + var m = k + 1; + scan: { + while (m + SimpleMask.group_len <= cell_count) { + const p = SimpleMask.eqlPrefix( + cells[0..cell_count], + m, + first, + ); + m += p; + if (p != SimpleMask.group_len) break :scan; + } + while (m < cell_count) : (m += 1) { + if (!SimpleMask.eqlScalar(cells[m], first)) break; + } + } + + // Fix up the style ref counts for the whole run at + // once. Each of the old cells held a reference to + // old_style so the release is safe by construction. + const n = m - k; + if (old_style != style.default_id) { + page.styles.releaseMultiple(page.memory, old_style, @intCast(n)); + } + if (style_id != style.default_id) { + page.styles.useMultiple(page.memory, style_id, @intCast(n)); + } + + for (k..m) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = m; + continue :fill; + } + + // General path for cells that failed the masked check: + // style-only mismatches are handled inline; anything that + // needs cleanup (wide chars and their spacers, grapheme + // data, hyperlinks) falls back to print(). + const general_count: usize = cells_per_cp; + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.wide != .narrow or + cell.hasGrapheme() or + cell.hyperlink) break :fill; + } + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.style_id != style_id) { + if (cell.style_id != style.default_id) { + page.styles.release(page.memory, cell.style_id); + } + if (style_id != style.default_id) { + page.styles.use(page.memory, style_id); + } + } + } + if (comptime width == .wide) { + cells[k] = @bitCast( + wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift), + ); + cells[k + 1] = @bitCast(spacer_bits); + } else { + cells[k] = @bitCast( + template_bits | (@as(u64, cps[printed + k]) << cp_shift), + ); + } + k += cells_per_cp; + } + + if (k > 0) { + assert(k % cells_per_cp == 0); + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]); + printed += k / cells_per_cp; + + // Advance the cursor. If we filled through the right limit + // then the cursor stays on the last cell with the pending + // wrap flag set, matching print(). + if (cursor.x + k >= right_limit) { + assert(cursor.x + k == right_limit); + screen.cursorRight(@intCast(k - 1)); + cursor.pending_wrap = true; + } else { + screen.cursorRight(@intCast(k)); + } + } + + // We hit a cell that requires the slow path. The cursor is + // exactly at that cell so return and let the caller print the + // next codepoint via print(). + if (k < cell_count) break; + } + + return printed; +} + +/// The expected value of a simple cell (per SimpleMask in +/// printSliceFill) that already has the given style (so no +/// ref-counting is needed). +inline fn printSliceCheckExpected(style_id: style.Id) u64 { + var e: Cell = @bitCast(@as(u64, 0)); + e.style_id = style_id; + return @bitCast(e); +} + pub fn print(self: *Terminal, c: u21) !void { // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); @@ -381,10 +949,16 @@ pub fn print(self: *Terminal, c: u21) !void { const grapheme_break = brk: { var state: uucode.grapheme.BreakState = .default; if (prev.cell.hasGrapheme()) { - const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + const cps = self.screens.active.cursor.page_pin.node.page().lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 }); - assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state)); + // With mode 2027 disabled, zero-width codepoints are + // attached without applying grapheme boundary rules. If + // the mode is enabled later, an existing cell can + // therefore contain one or more breaks. Feed those breaks + // into the state machine so it can reset its context and + // determine the boundary for the new codepoint. + _ = unicode.graphemeBreak(previous_codepoint, cp2, &state); previous_codepoint = cp2; } } @@ -396,33 +970,8 @@ pub fn print(self: *Terminal, c: u21) !void { // If we can NOT break, this means that "c" is part of a grapheme // with the previous char. if (!grapheme_break) { - var desired_wide: enum { no_change, wide, narrow } = .no_change; - - // If this is an emoji variation selector then we need to modify - // the cell width accordingly. VS16 makes the character wide and - // VS15 makes it narrow. - if (c == 0xFE0F or c == 0xFE0E) { - const prev_props = unicode.table.get(previous_codepoint); - // Check if it is a valid variation sequence in - // emoji-variation-sequences.txt, and if not, ignore the char. - if (!prev_props.emoji_vs_base) return; - - switch (c) { - 0xFE0F => desired_wide = .wide, - 0xFE0E => desired_wide = .narrow, - else => unreachable, - } - } else if (!unicode.table.get(c).width_zero_in_grapheme) { - // If we have a code point that contributes to the width of a - // grapheme, it necessarily means that we're at least at width - // 2, since the first code point must be at least width 1 to - // start. (Note that Prepend code points could effectively mean - // the first code point should be width 0, but we don't handle - // that yet.) - desired_wide = .wide; - } - - switch (desired_wide) { + switch (unicode.graphemeWidthEffect(previous_codepoint, c)) { + .ignore => return, .wide => wide: { if (prev.cell.wide == .wide) break :wide; @@ -463,19 +1012,19 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); prev.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { - const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { try self.screens.active.appendGrapheme(new_rac.cell, cp); } - old_pin.node.data.clearGrapheme(old_rac.cell); + old_pin.node.page().clearGrapheme(old_rac.cell); } - old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + old_pin.node.page().updateRowGraphemeFlag(old_rac.row); } // Point prev.cell to our new previous cell that @@ -539,7 +1088,7 @@ pub fn print(self: *Terminal, c: u21) !void { break :narrow; }, - else => {}, + .no_change => {}, } log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ @@ -742,7 +1291,7 @@ fn printCell( const spacer_cell = self.screens.active.cursorCellRight(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, spacer_cell[0..1], ); @@ -768,7 +1317,7 @@ fn printCell( const wide_cell = self.screens.active.cursorCellLeft(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, wide_cell[0..1], ); @@ -791,7 +1340,7 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); page.clearGrapheme(cell); page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); } @@ -800,7 +1349,7 @@ fn printCell( // cell's new style will be different after writing. const style_changed = cell.style_id != self.screens.active.cursor.style_id; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Release the old style. if (cell.style_id != style.default_id) { @@ -823,7 +1372,7 @@ fn printCell( }; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Use the new style. if (cell.style_id != style.default_id) { @@ -852,7 +1401,7 @@ fn printCell( }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); page.clearHyperlink(cell); page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } @@ -1216,10 +1765,8 @@ pub fn semanticPrompt( // within a prompt area to SGR mouse events and defers to the // shell to handle them. if (cmd.readOption(.click_events)) |v| { - if (v) { - screen.semantic_prompt.click = .click_events; - break :click; - } + screen.semantic_prompt.click = .{ .click_events = v }; + break :click; } // If click_events was not set or disabled, fallback to `cl`. @@ -1479,10 +2026,19 @@ pub fn index(self: *Terminal) !void { screen.kitty_images.dirty = true; } - // If our scrolling region is at the top, we create scrollback. + // If our scrolling region is at the top, we create scrollback, + // but only if our screen retains scrollback. If our screen + // doesn't retain scrollback (e.g. the alternate screen) then + // creating scrollback is pure overhead: the rows are never + // visible and are simply pruned later. In that case we use the + // in-place region scroll below, unless the region is a single + // row (a one row screen) which cursorScrollRegionUp can't + // handle (and cursorDownScroll special-cases). if (self.scrolling_region.top == 0 and self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) + self.scrolling_region.right == self.cols - 1 and + (!screen.no_scrollback or + self.scrolling_region.bottom == 0)) { try screen.cursorScrollAbove(); return; @@ -1490,49 +2046,18 @@ pub fn index(self: *Terminal) !void { // Slow path for left and right scrolling region margins. if (self.scrolling_region.left != 0 or - self.scrolling_region.right != self.cols - 1 or - - // PERF(mitchellh): If we have an SGR background set then - // we need to preserve that background in our erased rows. - // scrollUp does that but eraseRowBounded below does not. - // However, scrollUp is WAY slower. We should optimize this - // case to work in the eraseRowBounded codepath and remove - // this check. - !screen.blankCell().isZero()) + self.scrolling_region.right != self.cols - 1) { try self.scrollUp(1); return; } - // Otherwise use a fast path function from PageList to efficiently - // scroll the contents of the scrolling region. - - // Preserve old cursor just for assertions - const old_cursor = screen.cursor; - - try screen.pages.eraseRowBounded( - .{ .active = .{ .y = self.scrolling_region.top } }, + // Otherwise use a fast path function to efficiently scroll + // the contents of the scrolling region. + try screen.cursorScrollRegionUp( self.scrolling_region.bottom - self.scrolling_region.top, ); - // eraseRow and eraseRowBounded will end up moving the cursor pin - // up by 1, so we need to move it back down. A `cursorReload` - // would be better option but this is more efficient and this is - // a super hot path so we do this instead. - assert(screen.cursor.x == old_cursor.x); - assert(screen.cursor.y == old_cursor.y); - screen.cursor.y -= 1; - screen.cursorDown(1); - - // The operations above can prune our cursor style so we need to - // update. This should never fail because the above can only FREE - // memory. - screen.manualStyleUpdate() catch |err| { - std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - screen.cursor.style = .{}; - screen.manualStyleUpdate() catch unreachable; - }; - return; } @@ -1601,8 +2126,8 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; const col = if (col_req == 0) 1 else col_req; - const x = @min(params.x_max, col + params.x_offset) -| 1; - const y = @min(params.y_max, row + params.y_offset) -| 1; + const x = @min(params.x_max, col +| params.x_offset) -| 1; + const y = @min(params.y_max, row +| params.y_offset) -| 1; // If the y is unchanged then this is fast pointer math if (y == self.screens.active.cursor.y) { @@ -1691,9 +2216,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { // If our scroll region is at the top and we have no left/right // margins then we move the scrolled out text into the scrollback. + // + // If our screen doesn't retain scrollback (e.g. the alternate + // screen) then creating scrollback is pure overhead, so we use the + // deleteLines path below instead, unless the region is the full + // screen where cursorScrollAbove has a specialized fast path + // (cursorDownScroll) for scrolling without scrollback. if (self.scrolling_region.top == 0 and self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) + self.scrolling_region.right == self.cols - 1 and + (!self.screens.active.no_scrollback or + self.scrolling_region.bottom == self.rows - 1)) { // Scrolling dirties the images because it updates their placements pins. if (comptime build_options.kitty_graphics) { @@ -1731,10 +2264,18 @@ pub const ScrollViewport = union(Tag) { /// Scroll by some delta amount, up is negative. delta: isize, + /// Scroll to the given absolute row offset from the top of the + /// scrollable area. A value of zero is the top row. The requested + /// row becomes the first visible row of the viewport, clamped so + /// the viewport never scrolls beyond the top of the active area. + /// This is the same row space as PageList.Scrollbar offset. + row: usize, + pub const Tag = lib.Enum(lib.target, &.{ "top", "bottom", "delta", + "row", }); const c_union = lib.TaggedUnion( @@ -1755,9 +2296,80 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { .top => .{ .top = {} }, .bottom => .{ .active = {} }, .delta => |delta| .{ .delta_row = delta }, + .row => |row| .{ .row = row }, }); } +/// Return the current compression activity value. +/// +/// Callers should schedule a `compress` call whenever this value changes. The +/// direction of the change has no meaning; this is an opaque change token +/// rather than a monotonic sequence exposed by Terminal. +/// +/// It is up to the terminal what it decides to compress, but currently +/// we compress cold (non-viewed, non-editable) scrollback history on +/// the primary screen. +/// +/// Note that compression requires specific system features, namely +/// the ability to retain virtual memory allocations while discarding their +/// physical memory backings. Callers must still use `compress` to determine +/// whether compression is supported on the current target. +pub fn compressionActivity(self: *const Terminal) u64 { + const state = &self.screens.get(.primary).?.pages.page_compression; + // For now we don't use the extra 16 bits. + return @as(u64, state.activity_serial); +} + +/// The amount of compression work performed by `compress` before returning. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionMode = lib.Enum(lib.target, &.{ + "incremental", + "full", +}); + +/// The scheduling result of a `compress` call. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionResult = lib.Enum(lib.target, &.{ + "unsupported", + "pending", + "complete", +}); + +/// Compress cold memory to save resident memory space. +/// +/// Full compression does a full pass compressing everything it can before +/// returning. This is not recommended for interactive terminals because +/// compression is relatively slow and with large scrollbacks this can cause +/// stalls. +/// +/// Incremental compression bounds itself on how much data it can look +/// up to compress and how much compression work it does before returning. +/// It is stateful (we maintain the state) and the return value tells callers +/// whether they should continue calling it in the future. +/// +/// Callers should schedule compression when it doesn't impact user +/// experience, for example during idle times. +pub fn compress( + self: *Terminal, + mode: CompressionMode, +) CompressionResult { + const pages = &self.screens.get(.primary).?.pages; + const result = switch (mode) { + .incremental => pages.compress(.incremental), + .full => pages.compress(.full), + }; + + return switch (result) { + .unsupported => .unsupported, + .pending => .pending, + .complete => .complete, + }; +} + /// To be called before shifting a row (as in insertLines and deleteLines) /// /// Takes care of boundary conditions such as potentially split wide chars @@ -1817,6 +2429,21 @@ fn rowWillBeShifted( } } +/// Renew every live page generation in an inclusive range before a full-width +/// line operation moves logical rows between their coordinates. +fn invalidateFullWidthRowRange( + self: *Terminal, + first: *PageList.List.Node, + last: *PageList.List.Node, +) void { + var node = first; + while (true) : (node = node.next.?) { + // Full-width line movement remaps cached row coordinates in this page. + self.screens.active.pages.invalidateNodeLayout(node); + if (node == last) break; + } +} + // TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical, // the majority of their logic can (and should) be abstracted in to // a single shared helper function, probably on `Screen` not here. @@ -1892,6 +2519,12 @@ pub fn insertLines(self: *Terminal, count: usize) void { }; defer self.screens.active.pages.untrackPin(cur_p); + // Partial-width margins edit cells in stable rows; full-width moves rows. + if (!left_right) self.invalidateFullWidthRowRange( + self.screens.active.cursor.page_pin.node, + cur_p.node, + ); + // Our current y position relative to the cursor var y: usize = rem; @@ -1906,8 +2539,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -1925,8 +2558,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -1989,11 +2622,11 @@ pub fn insertLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2005,8 +2638,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it has been shifted. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2087,6 +2720,12 @@ pub fn deleteLines(self: *Terminal, count: usize) void { }; defer self.screens.active.pages.untrackPin(cur_p); + // Partial-width margins edit cells in stable rows; full-width moves rows. + if (!left_right) self.invalidateFullWidthRowRange( + cur_p.node, + cur_p.down(rem - 1).?.node, + ); + // Our current y position relative to the cursor var y: usize = 0; @@ -2101,8 +2740,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -2120,8 +2759,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -2177,11 +2816,11 @@ pub fn deleteLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2193,8 +2832,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it's from out of bounds. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2248,7 +2887,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. @@ -2331,7 +2970,7 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Remaining cols from our cursor to the right margin. const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; @@ -2408,7 +3047,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // mode was not ISO we also always ignore protection attributes. if (self.screens.active.protected_mode != .iso) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -2416,7 +3055,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -2488,7 +3127,7 @@ pub fn eraseLine( // to fill the entire line. if (!protected) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -2496,7 +3135,7 @@ pub fn eraseLine( } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -2670,7 +3309,7 @@ pub fn decaln(self: *Terminal) !void { // Fill with Es by moving the cursor but reset it after. while (true) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); const row = self.screens.active.cursor.page_row; const cells_multi: [*]Cell = row.cells.ptr(page.memory); const cells = cells_multi[0..page.size.cols]; @@ -2720,6 +3359,22 @@ pub fn kittyGraphics( return kitty.graphics.execute(alloc, self, cmd); } +/// Execute a Glyph Protocol APC command against this terminal's per-session +/// glossary. The returned response, if any, should be sent back to the pty as +/// a complete APC sequence via `Response.formatWire`. +pub fn glyphProtocol( + self: *Terminal, + alloc: Allocator, + req: *const glyph.Request, +) ?glyph.Response { + const resp = glyph.execute(alloc, &self.glyph_glossary, req); + switch (req.*) { + .register, .clear => self.flags.dirty.glyph_glossary = true, + .support, .query => {}, + } + return resp; +} + /// Set the storage size limit for Kitty graphics across all screens. pub fn setKittyGraphicsSizeLimit( self: *Terminal, @@ -2890,8 +3545,13 @@ pub fn resize( // Resize our tabstops if (self.cols != cols) { + const tabstops: Tabstops = try .init( + alloc, + cols, + TABSTOP_INTERVAL, + ); self.tabstops.deinit(alloc); - self.tabstops = try .init(alloc, cols, 8); + self.tabstops = tabstops; } // Resize primary screen, which supports reflow @@ -2926,13 +3586,33 @@ pub fn resize( }; } +test "Terminal: resize preserves tabstops on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1)); + + try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); + try testing.expect(t.tabstops.get(8)); +} + /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { - self.pwd.clearRetainingCapacity(); - if (pwd.len > 0) { - try self.pwd.appendSlice(self.gpa(), pwd); - try self.pwd.append(self.gpa(), 0); + if (pwd.len == 0) { + self.pwd.clearRetainingCapacity(); + return; } + + const capacity = std.math.add(usize, pwd.len, 1) catch + return error.OutOfMemory; + try self.pwd.ensureTotalCapacity(self.gpa(), capacity); + + self.pwd.items.len = capacity; + std.mem.copyForwards(u8, self.pwd.items[0..pwd.len], pwd); + self.pwd.items[pwd.len] = 0; } /// Returns the pwd for the terminal, if any. The memory is owned by the @@ -2942,13 +3622,41 @@ pub fn getPwd(self: *const Terminal) ?[:0]const u8 { return self.pwd.items[0 .. self.pwd.items.len - 1 :0]; } +test "Terminal: setPwd preserves a sentinel on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 5, .rows = 1 }); + defer t.deinit(alloc); + + try t.pwd.ensureTotalCapacityPrecise(alloc, 3); + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.setPwd("pwd")); + try testing.expect(t.getPwd() == null); +} + +test "Terminal: setPwd accepts its current value" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + try t.setPwd("file:///tmp"); + try t.setPwd(t.getPwd().?); + try testing.expectEqualStrings("file:///tmp", t.getPwd().?); +} + /// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2). pub fn setTitle(self: *Terminal, t: []const u8) !void { - self.title.clearRetainingCapacity(); - if (t.len > 0) { - try self.title.appendSlice(self.gpa(), t); - try self.title.append(self.gpa(), 0); + if (t.len == 0) { + self.title.clearRetainingCapacity(); + return; } + + const capacity = std.math.add(usize, t.len, 1) catch + return error.OutOfMemory; + try self.title.ensureTotalCapacity(self.gpa(), capacity); + + self.title.items.len = capacity; + std.mem.copyForwards(u8, self.title.items[0..t.len], t); + self.title.items[t.len] = 0; } /// Returns the title for the terminal, if any. The memory is owned by the @@ -2958,6 +3666,27 @@ pub fn getTitle(self: *const Terminal) ?[:0]const u8 { return self.title.items[0 .. self.title.items.len - 1 :0]; } +test "Terminal: setTitle preserves a sentinel on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 5, .rows = 1 }); + defer t.deinit(alloc); + + try t.title.ensureTotalCapacityPrecise(alloc, 5); + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.setTitle("title")); + try testing.expect(t.getTitle() == null); +} + +test "Terminal: setTitle accepts its current value" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + try t.setTitle("Ghostty"); + try t.setTitle(t.getTitle().?); + try testing.expectEqualStrings("Ghostty", t.getTitle().?); +} + /// Switch to the given screen type (alternate or primary). /// /// This does NOT handle behaviors such as clearing the screen, @@ -3171,6 +3900,7 @@ pub fn fullReset(self: *Terminal) void { self.previous_char = null; self.pwd.clearRetainingCapacity(); self.title.clearRetainingCapacity(); + self.glyph_glossary.clearAndFree(self.gpa()); self.status_display = .main; self.scrolling_region = .{ .top = 0, @@ -3193,6 +3923,24 @@ fn clearDirty(t: *Terminal) void { t.screens.active.pages.clearDirty(); } +test "Terminal: setCursorPos saturates overflowing origin offsets" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.scrolling_region = .{ + .top = 2, + .bottom = 7, + .left = 3, + .right = 8, + }; + t.modes.set(.origin, true); + + t.setCursorPos(std.math.maxInt(usize), std.math.maxInt(usize)); + try testing.expectEqual(@as(size.CellCountInt, 8), t.screens.active.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 7), t.screens.active.cursor.y); +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 40, .rows = 40 }); @@ -3288,19 +4036,19 @@ test "Terminal: input glitch text" { // Get our initial grapheme capacity. const grapheme_cap = cap: { const page = t.screens.active.pages.pages.first.?; - break :cap page.data.capacity.grapheme_bytes; + break :cap page.capacity().grapheme_bytes; }; // Print glitch text until our capacity changes while (true) { const page = t.screens.active.pages.pages.first.?; - if (page.data.capacity.grapheme_bytes != grapheme_cap) break; + if (page.capacity().grapheme_bytes != grapheme_cap) break; try t.printString(glitch); } // We're testing to make sure that grapheme capacity gets increased. const page = t.screens.active.pages.pages.first.?; - try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); + try testing.expect(page.capacity().grapheme_bytes > grapheme_cap); } test "Terminal: zero-width character at start" { @@ -3536,7 +4284,7 @@ test "Terminal: print over wide char with bold" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3547,7 +4295,7 @@ test "Terminal: print over wide char with bold" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3566,7 +4314,7 @@ test "Terminal: print over wide char with bg color" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3577,7 +4325,7 @@ test "Terminal: print over wide char with bg color" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3608,7 +4356,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -3617,7 +4365,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; @@ -3625,7 +4373,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -3634,7 +4382,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; @@ -3642,7 +4390,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; @@ -3650,12 +4398,66 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +test "Terminal: enabling grapheme mode handles stored breaks" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, false); + try t.print('a'); + try t.print(0x200B); // Zero width space is stored on the prior cell. + + t.modes.set(.grapheme_cluster, true); + try t.print(0x0301); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("a\xE2\x80\x8B\xCC\x81", str); +} + +// Terminal.print receives one codepoint at a time, so it can't use +// unicode.graphemeWidth directly; that API requires a complete buffered +// cluster or string end. This keeps the streaming printer's cursor advance +// in sync with the buffered measurement API for representative clusters. +fn expectGraphemeWidthParity(cps: []const u21) !void { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + + var expected: usize = 0; + var i: usize = 0; + while (i < cps.len) { + const result = unicode.graphemeWidth(u21, cps[i..]); + try testing.expect(result.len > 0); + i += result.len; + expected += result.width; + } + + for (cps) |cp| try t.print(cp); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(expected, t.screens.active.cursor.x); +} + +test "Terminal: graphemeWidth parity" { + try expectGraphemeWidthParity(&.{ 0x2764, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 'x', 0xFE0F, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x231A, 0xFE0E, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 }); + try expectGraphemeWidthParity(&.{ 0x23, 0xFE0F, 0x20E3 }); + try expectGraphemeWidthParity(&.{ '1', 0x20E3 }); + try expectGraphemeWidthParity(&.{ 0x1F44B, 0x1F3FF }); + try expectGraphemeWidthParity(&.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 }); + try expectGraphemeWidthParity(&.{ 'a', 'b' }); + try expectGraphemeWidthParity(&.{ 0x0301, 0x0302 }); +} + test "Terminal: VS16 doesn't make character with 2027 disabled" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); @@ -3678,7 +4480,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -3759,7 +4561,7 @@ test "Terminal: variation selectors apply to preceding codepoint" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.page().lookupGrapheme(cell).?); } test "Terminal: print multicodepoint grapheme, mode 2027" { @@ -3792,7 +4594,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 4), cps.len); } { @@ -4000,7 +4802,7 @@ test "Terminal: VS15 to make narrow character" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4035,7 +4837,7 @@ test "Terminal: VS15 on already narrow emoji" { try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4094,7 +4896,7 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4146,7 +4948,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -4197,7 +4999,7 @@ test "Terminal: VS16 to make wide character on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4248,7 +5050,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expect(cell.hyperlink); } @@ -4286,7 +5088,7 @@ test "Terminal: VS16 to make wide character with pending wrap" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4325,7 +5127,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4356,7 +5158,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -4365,7 +5167,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4458,7 +5260,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -4487,7 +5289,7 @@ test "Terminal: print Devanagari grapheme should be wide" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4535,7 +5337,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4556,7 +5358,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { t.cursorDown(rows - 1); - for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + for (rows..t.screens.active.pages.pages.first.?.capacity().rows) |_| { try t.index(); } @@ -4594,7 +5396,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4627,7 +5429,7 @@ test "Terminal: print invalid VS16 with second char (combining)" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -4689,7 +5491,7 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -4729,7 +5531,7 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -4780,7 +5582,7 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe try testing.expect(!cell.hasGrapheme()); // This is what we'd expect if we did break correctly: //try testing.expect(cell.hasGrapheme()); - //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.data.lookupGrapheme(cell).?); + //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -5228,7 +6030,7 @@ test "Terminal: print with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5255,7 +6057,7 @@ test "Terminal: print over cell with same hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5282,7 +6084,7 @@ test "Terminal: print and end hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5317,7 +6119,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5327,7 +6129,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 2), id); } @@ -5351,7 +6153,7 @@ test "Terminal: overwrite hyperlink" { .x = @intCast(x), .y = 0, } }).?; - const page = &list_cell.node.data; + const page = list_cell.node.page(); const row = list_cell.row; try testing.expect(!row.hyperlink); const cell = list_cell.cell; @@ -6092,8 +6894,11 @@ test "Terminal: insertLines simple" { try t.printString("GHI"); t.setCursorPos(2, 2); + const node = t.screens.active.cursor.page_pin.node; + const serial = node.serial; t.clearDirty(); t.insertLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial)); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -6166,7 +6971,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(2, 2); @@ -6250,7 +7055,7 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -6271,11 +7076,15 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { try t.printString("5EEEE"); // Verify we now have a second page - try testing.expect(first_page.next != null); + const second_page = first_page.next.?; + const first_serial = first_page.serial; + const second_serial = second_page.serial; t.setCursorPos(1, 1); t.clearDirty(); t.insertLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial)); + try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial)); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -6582,9 +7391,9 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -6596,7 +7405,7 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6633,7 +7442,7 @@ test "Terminal: scrollUp clears hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6735,7 +7544,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -6747,9 +7556,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -6759,7 +7568,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6775,9 +7584,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -6787,7 +7596,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -6799,9 +7608,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7082,9 +7891,9 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -7096,7 +7905,7 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7206,9 +8015,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -7218,7 +8027,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -7230,9 +8039,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7246,7 +8055,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -7258,9 +8067,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -7270,7 +8079,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7504,7 +8313,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(1, 1); @@ -7581,7 +8390,7 @@ test "Terminal: eraseChars wide char boundary conditions" { t.setCursorPos(1, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7611,7 +8420,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { t.setCursorPos(1, 6); // At: て t.eraseChars(4); // Delete: て下 - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7638,7 +8447,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7941,7 +8750,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -7953,7 +8762,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8115,7 +8924,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -8127,7 +8936,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8164,9 +8973,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -8381,6 +9190,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" { } } +test "Terminal: index bottom of scroll region with top margin and background SGR" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("1\n2\n3\n4\n5"); + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(4, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + // The region (rows 2-4) scrolled up, rows outside are unchanged. + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n3\n4\n\n5", str); + } + + // The cursor is on the new blank row. + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y); + + // The new blank row must be filled with our background color. + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 3, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: index bottom of alt screen full region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 5 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + try t.printString("A\nB\nC"); + try t.index(); + t.carriageReturn(); + try t.print('D'); + + // Content scrolled up and the scrolled-out row is discarded, NOT + // moved into scrollback (the alt screen has none). + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\nD", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\nD", str); + } + + // Primary screen is untouched. + try t.switchScreenMode(.@"1049", false); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: index bottom of alt screen top region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + try t.printString("1\n2\n3\n4\n5"); + + // Region at the top of the screen, excluding the last row. On the + // alt screen this must NOT create scrollback. + t.setTopAndBottomMargin(1, 4); + t.setCursorPos(4, 1); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\n4\nX\n5", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\n4\nX\n5", str); + } +} + +test "Terminal: scrollUp top region no scrollback" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("A\nB\nC\nD\nE"); + t.setTopAndBottomMargin(1, 3); + try t.scrollUp(1); + + // The region scrolled and the scrolled-out row is discarded. + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\n\nD\nE", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\n\nD\nE", str); + } +} + test "Terminal: cursorUp basic" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -8857,8 +9788,11 @@ test "Terminal: deleteLines simple" { try t.printString("GHI"); t.setCursorPos(2, 2); + const node = t.screens.active.cursor.page_pin.node; + const serial = node.serial; t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial)); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -8919,7 +9853,7 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -8940,11 +9874,15 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { try t.printString("5EEEE"); // Verify we now have a second page - try testing.expect(first_page.next != null); + const second_page = first_page.next.?; + const first_serial = first_page.serial; + const second_serial = second_page.serial; t.setCursorPos(1, 1); t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial)); + try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial)); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -9534,7 +10472,7 @@ test "Terminal: bold style" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); } } @@ -9558,7 +10496,7 @@ test "Terminal: garbage collect overwritten" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -9580,7 +10518,7 @@ test "Terminal: do not garbage collect old styles in use" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -9971,7 +10909,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10007,7 +10945,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10075,7 +11013,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (0..2) |x| { @@ -10085,7 +11023,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -10115,7 +11053,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -10688,7 +11626,7 @@ test "Terminal: deleteChars wide char boundary conditions" { t.setCursorPos(1, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -10740,7 +11678,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -10779,7 +11717,7 @@ test "Terminal: deleteChars wide char across right margin" { t.setCursorPos(1, 2); t.deleteChars(1); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); // NOTE: This behavior is slightly inconsistent with xterm. xterm // _visually_ splits the wide character (half the wide character shows @@ -10940,14 +11878,14 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" { // Fill the style map to max capacity const max_styles = std.math.maxInt(size.CellCountInt); - while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (t.screens.active.cursor.page_pin.node.capacity().styles < max_styles) { _ = t.screens.active.increaseCapacity( t.screens.active.cursor.page_pin.node, .styles, ) catch break; } - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill all style slots using the StyleSet's layout capacity which accounts @@ -11545,6 +12483,234 @@ test "Terminal: printRepeat no previous character" { } } +test "Terminal: printSlice simple ascii" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' }); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(u21, 'o'), t.previous_char.?); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "Terminal: printSlice wraps and scrolls" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more. + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' }); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("fghij\nkl", str); + } + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); +} + +test "Terminal: printSlice pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' }); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("abcde", str); + } +} + +/// Differential testing helper: applies the same logical print +/// operations to two terminals, one using per-codepoint print() and +/// the other using printSlice() with random chunking, verifying that +/// the results are identical. +fn testPrintSliceDifferential( + alloc: Allocator, + rand: std.Random, + ops: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + var t1 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t1.deinit(alloc); + var t2 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t2.deinit(alloc); + + // Alphabet of interesting codepoints: ascii, latin-1, combining + // marks, CJK (wide), emoji (wide), ZWJ, variation selectors. + const alphabet = [_]u21{ + 'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F, + 'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F, + 'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q', + 'r', 's', 't', 'u', 'v', 'w', '1', '2', + 0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1, + }; + + var cps_buf: [64]u32 = undefined; + var last_n: usize = 0; + + for (0..ops) |_| { + switch (rand.intRangeAtMost(u8, 0, 20)) { + // Print a run of codepoints (most common op). + 0...9 => { + const n = rand.intRangeAtMost(usize, 1, cps_buf.len); + last_n = n; + for (cps_buf[0..n]) |*cp| { + cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)]; + } + + // t1: per-codepoint print + for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp)); + + // t2: printSlice with random chunking + var i: usize = 0; + while (i < n) { + const chunk = rand.intRangeAtMost(usize, 1, n - i); + try t2.printSlice(cps_buf[i..][0..chunk]); + i += chunk; + } + }, + 10 => { + t1.carriageReturn(); + t2.carriageReturn(); + try t1.linefeed(); + try t2.linefeed(); + }, + 11 => { + const row = rand.intRangeAtMost(usize, 1, rows); + const col = rand.intRangeAtMost(usize, 1, cols); + t1.setCursorPos(row, col); + t2.setCursorPos(row, col); + }, + 12 => { + const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => .{ .unset = {} }, + 1 => .{ .bold = {} }, + 2 => .{ .direct_color_fg = .{ + .r = rand.int(u8), + .g = rand.int(u8), + .b = rand.int(u8), + } }, + 3 => .{ .@"8_fg" = .red }, + else => unreachable, + }; + try t1.setAttribute(attr); + try t2.setAttribute(attr); + }, + 13 => { + const v = rand.boolean(); + t1.modes.set(.insert, v); + t2.modes.set(.insert, v); + }, + 14 => { + const v = rand.boolean(); + t1.modes.set(.wraparound, v); + t2.modes.set(.wraparound, v); + }, + 15 => { + const v = rand.boolean(); + t1.modes.set(.grapheme_cluster, v); + t2.modes.set(.grapheme_cluster, v); + }, + 16 => { + // Margins. + t1.modes.set(.enable_left_and_right_margin, true); + t2.modes.set(.enable_left_and_right_margin, true); + const left = rand.intRangeAtMost(usize, 1, cols / 2); + const right = rand.intRangeAtMost(usize, cols / 2, cols); + t1.setLeftAndRightMargin(left, right); + t2.setLeftAndRightMargin(left, right); + }, + 17 => { + t1.setLeftAndRightMargin(0, 0); + t2.setLeftAndRightMargin(0, 0); + }, + 18 => { + try t1.screens.active.startHyperlink("http://example.com", null); + try t2.screens.active.startHyperlink("http://example.com", null); + }, + 19 => { + t1.screens.active.endHyperlink(); + t2.screens.active.endHyperlink(); + }, + 20 => { + const set: charsets.Charset = if (rand.boolean()) + .dec_special + else + .utf8; + t1.configureCharset(.G0, set); + t2.configureCharset(.G0, set); + }, + else => unreachable, + } + + // Cursor state must match exactly after every op. + try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y); + try testing.expectEqual( + t1.screens.active.cursor.pending_wrap, + t2.screens.active.cursor.pending_wrap, + ); + + // Full screen contents must match after every op. On failure, + // dump diagnostics that make the failure reproducible. + { + const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str1); + const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str2); + testing.expectEqualStrings(str1, str2) catch |err| { + std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]}); + std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{ + t1.modes.get(.grapheme_cluster), + t1.modes.get(.insert), + t1.modes.get(.wraparound), + t1.scrolling_region.left, + t1.scrolling_region.right, + cols, + }); + return err; + }; + } + } + + // Page integrity (styles refcounts, grapheme maps, etc.) must hold. + try t1.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); + try t2.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); +} + +test "Terminal: printSlice differential fuzz vs print" { + const alloc = testing.allocator; + + // Multiple seeds and terminal sizes for coverage, including a + // tiny terminal to stress wrap/scroll edge cases. + var prng = std.Random.DefaultPrng.init(0xC0FFEE); + const rand = prng.random(); + try testPrintSliceDifferential(alloc, rand, 500, 80, 24); + try testPrintSliceDifferential(alloc, rand, 500, 10, 4); + try testPrintSliceDifferential(alloc, rand, 500, 5, 2); + try testPrintSliceDifferential(alloc, rand, 200, 2, 2); +} + test "Terminal: printAttributes" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -12394,7 +13560,24 @@ test "Terminal: OSC133A click_events=1 sets click to click_events" { .options_unvalidated = "click_events=1", }); - try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A click_events=2 sets click to click_events (relative)" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Verify default state is none + try testing.expectEqual(.none, t.screens.active.semantic_prompt.click); + + // OSC 133;A with click_events=2 + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=2", + }); + + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .relative }, t.screens.active.semantic_prompt.click); } test "Terminal: OSC133A click_events=0 does not set click_events" { @@ -12451,7 +13634,7 @@ test "Terminal: OSC133A click_events=1 takes priority over cl" { }); // click_events should take priority - try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click); } test "Terminal: OSC133A click_events=0 falls back to cl" { @@ -12988,7 +14171,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13003,7 +14186,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13075,7 +14258,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13090,7 +14273,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13183,3 +14366,35 @@ test "Terminal: deleteLines wide char at right margin with full clear" { // violation in clearCells. try t.scrollUp(t.rows); } + +test "Terminal: glyph APC stores session glossary entries" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024); + defer register_parser.deinit(); + for ("r;cp=e0a0;AAAAAAAAAAAAAA==") |byte| try register_parser.feed(byte); + var register_req = try register_parser.complete(alloc); + defer register_req.deinit(alloc); + + try testing.expectEqual(glyph.Response{ + .register = .{ .cp = 0xE0A0 }, + }, t.glyphProtocol(alloc, ®ister_req).?); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); + try testing.expect(t.flags.dirty.glyph_glossary); + + var query_parser = glyph.CommandParser.init(alloc, 1024 * 1024); + defer query_parser.deinit(); + for ("q;cp=e0a0") |byte| try query_parser.feed(byte); + var query_req = try query_parser.complete(alloc); + defer query_req.deinit(alloc); + + try testing.expectEqual(glyph.Response{ .query = .{ + .cp = 0xE0A0, + .status = .{ .glossary = true }, + } }, t.glyphProtocol(alloc, &query_req).?); + + t.fullReset(); + try testing.expect(!t.glyph_glossary.contains(0xE0A0)); +} diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig index 7e6f08a7..121f906a 100644 --- a/vendor/libghostty-vt/src/terminal/apc.zig +++ b/vendor/libghostty-vt/src/terminal/apc.zig @@ -2,6 +2,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; +pub const glyph = @import("apc/glyph.zig"); const kitty_gfx = @import("kitty/graphics.zig"); const log = std.log.scoped(.terminal_apc); @@ -18,15 +19,27 @@ pub const Handler = struct { /// use `.initFull`. max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{ .kitty = Protocol.defaultMaxBytes(.kitty), + .glyph = Protocol.defaultMaxBytes(.glyph), }), + /// Protocols recognized by this APC handler. When a protocol is absent, + /// matching APC sequences are ignored so callers see the same behavior as + /// an unsupported protocol: no command execution and no response. + enabled: std.EnumSet(Protocol) = .initFull(), + pub fn deinit(self: *Handler) void { self.state.deinit(); } pub fn start(self: *Handler) void { self.state.deinit(); - self.state = .{ .identify = {} }; + self.state = .{ .identify = .{} }; + } + + /// Enable or disable APC protocol recognition for future APC sequences. + /// This does not affect any APC command already being parsed. + pub fn enable(self: *Handler, protocol: Protocol, enabled: bool) void { + self.enabled.setPresent(protocol, enabled); } pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { @@ -38,21 +51,50 @@ pub const Handler = struct { .ignore => return, // We identify the APC command by the first byte. - .identify => { - switch (byte) { - // Kitty graphics protocol - 'G' => self.state = if (comptime build_options.kitty_graphics) - .{ .kitty = .init( + .identify => |*id| id: { + // Kitty graphics is detected immediately on the `G` byte, + // since commands begin immediately after with no termination + // character after the 'G'. + if (comptime build_options.kitty_graphics) { + if (id.len == 0 and + byte == 'G' and + self.enabled.contains(.kitty)) + { + self.state = .{ .kitty = .init( alloc, self.max_bytes.get(.kitty) orelse Protocol.defaultMaxBytes(.kitty), - ) } - else - .ignore, + ) }; + break :id; + } + } - // Unknown - else => self.state = .ignore, + // If we hit `;` then identify... + if (byte == ';') { + const str = id.buf[0..id.len]; + if (std.mem.eql(u8, str, "25a1") and + self.enabled.contains(.glyph)) + { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } + + break :id; } + + // If we're out of space to buffer then we're done. + if (id.len >= id.buf.len) { + self.state = .ignore; + break :id; + } + + id.buf[id.len] = byte; + id.len += 1; }, .kitty => |*p| if (comptime build_options.kitty_graphics) { @@ -62,6 +104,53 @@ pub const Handler = struct { self.state = .ignore; }; } else unreachable, + + .glyph => |*p| p.feed(byte) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }, + } + } + + /// Feed a slice of bytes to the handler. This is equivalent to + /// calling feed for each byte in order, but protocol payload bytes + /// are passed through in bulk so large payloads (e.g. Kitty graphics + /// images) avoid per-byte dispatch overhead. + pub fn feedSlice(self: *Handler, alloc: Allocator, bytes: []const u8) void { + var rem = bytes; + while (rem.len > 0) { + switch (self.state) { + .inactive => unreachable, + + // We're ignoring this APC command; drop the whole slice. + .ignore => return, + + // Identification consumes at most a few bytes; step + // through them one at a time until the state changes. + .identify => { + self.feed(alloc, rem[0]); + rem = rem[1..]; + }, + + .kitty => |*p| if (comptime build_options.kitty_graphics) { + p.feedSlice(rem) catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + return; + } else unreachable, + + .glyph => |*p| { + p.feedSlice(rem) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + return; + }, + } } } @@ -86,21 +175,40 @@ pub const Handler = struct { break :kitty .{ .kitty = command }; }, + + .glyph => |*p| glyph_cmd: { + const command = p.complete(p.alloc) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + break :glyph_cmd null; + }; + + break :glyph_cmd .{ .glyph = command }; + }, }; } }; pub const State = union(enum) { /// We're not in the middle of an APC command yet. - inactive: void, + inactive, /// We got an unrecognized APC sequence or the APC sequence we /// recognized became invalid. We're just dropping bytes. - ignore: void, - - /// We're waiting to identify the APC sequence. This is done by - /// inspecting the first byte of the sequence. - identify: void, + ignore, + + /// We're waiting to identify the APC sequence. The way this is done + /// is pretty fluid depending on supported APC protocols, but for now + /// our rule is: + /// + /// * 'G' - immediate transition to Kitty graphics protocol + /// * Buffer up to `;` and the bytes before dictate the protocol. + /// If we overflow then we're immediately invalid because we don't + /// support anything longer than this. + /// + identify: struct { + len: u3 = 0, + buf: [4]u8 = undefined, + }, /// Kitty graphics protocol kitty: if (build_options.kitty_graphics) @@ -108,9 +216,13 @@ pub const State = union(enum) { else void, + /// Glyph protocol + glyph: glyph.CommandParser, + pub fn deinit(self: *State) void { switch (self.*) { .inactive, .ignore, .identify => {}, + .glyph => |*v| v.deinit(), .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() else @@ -122,6 +234,7 @@ pub const State = union(enum) { /// Possible APC command types. pub const Protocol = enum { kitty, + glyph, /// Returns the default maximum bytes for the given protocol. pub fn defaultMaxBytes(self: Protocol) usize { @@ -129,6 +242,10 @@ pub const Protocol = enum { // Kitty graphics payloads can be very large (e.g. full images // encoded as base64), so the default is set to 65 MiB. .kitty => 65 * 1024 * 1024, + // Glyph protocol messages carry single glyf outlines which + // are small, but base64 encoding inflates them. 1 MiB is + // generous for any single simple-glyph record. + .glyph => 1 * 1024 * 1024, }; } }; @@ -140,12 +257,16 @@ pub const Command = union(Protocol) { else void, + glyph: glyph.Request, + pub fn deinit(self: *Command, alloc: Allocator) void { switch (self.*) { .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit(alloc) else unreachable, + + .glyph => |*v| v.deinit(alloc), } } }; @@ -246,3 +367,156 @@ test "valid Kitty command" { defer cmd.deinit(alloc); try testing.expect(cmd == .kitty); } + +test "identify with unrecognized command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcd;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify buffer overflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcde;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify with no input" { + const testing = std.testing; + + var h: Handler = .{}; + h.start(); + try testing.expect(h.end() == null); +} + +test "identify with unknown partial input" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "garbage glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;X") |c| h.feed(alloc, c); + + try testing.expect(h.end() == null); +} + +test "valid glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .glyph); + try testing.expect(cmd.glyph == .query); +} + +test "feedSlice valid Kitty command" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8="); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); + + // The payload is base64-decoded by the parser on completion. + try testing.expectEqualStrings("hello", cmd.kitty.data); +} + +test "feedSlice identify split across slices" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "G"); + h.feedSlice(alloc, "f=24,s=10,"); + h.feedSlice(alloc, "v=20;aGVsbG8="); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); + + // The payload is base64-decoded by the parser on completion. + try testing.expectEqualStrings("hello", cmd.kitty.data); +} + +test "feedSlice unknown APC command is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "Xabcdef1234"); + try testing.expect(h.state == .ignore); + h.feedSlice(alloc, "more data that is dropped"); + try testing.expect(h.end() == null); +} + +test "feedSlice valid glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "25a1;q;cp=E0A0"); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .glyph); + try testing.expect(cmd.glyph == .query); +} + +test "feedSlice kitty max bytes exceeded" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) }; + defer h.deinit(); + h.start(); + h.feedSlice(alloc, "Ga=t;abcd"); + try testing.expect(h.state != .ignore); + h.feedSlice(alloc, "e"); + try testing.expect(h.state == .ignore); +} + +test "disabled glyph command is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.enable(.glyph, false); + h.start(); + for ("25a1;q;cp=e0a0") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph.zig b/vendor/libghostty-vt/src/terminal/apc/glyph.zig new file mode 100644 index 00000000..727e1ab4 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph.zig @@ -0,0 +1,164 @@ +//! # Glyph Protocol +//! +//! The Glyph Protocol lets applications register custom glyphs with the +//! terminal at runtime and query whether a given codepoint is already +//! covered by a system font or a prior registration. It eliminates the +//! requirement for users to install patched fonts (e.g. Nerd Fonts) in +//! order to render icons in TUIs. +//! +//! This file documents the current wire protocol surface parsed and formatted +//! by the glyph APC modules. +//! +//! ## Transport +//! +//! Messages use APC (Application Program Command) framing. +//! Terminals that do not implement the protocol can safely ignore APC +//! sequences. Every message is prefixed with the identifier `25a1` +//! (U+25A1 WHITE SQUARE — the canonical tofu symbol). +//! +//! ## Framing +//! +//! ``` +//! ESC _ 25a1 ; <verb> [ ; key=value ]* [ ; <payload> ] ESC \ +//! ``` +//! +//! Four verbs are defined: +//! +//! - `s` — support query +//! - `q` — codepoint query +//! - `r` — register a glyph +//! - `c` — clear registrations +//! +//! ## Support (`s`) +//! +//! Detects whether the terminal implements Glyph Protocol and which +//! payload formats it supports. +//! +//! Request: `ESC _ 25a1 ; s ESC \` +//! Response: `ESC _ 25a1 ; s ; fmt=<list> ESC \` +//! +//! `fmt` is a comma-separated list of supported payload format names: +//! - `glyf` — TrueType simple glyphs (required in v1) +//! - `colrv0` — COLR v0 layered flat-colour glyphs +//! - `colrv1` — COLR v1 paint-graph glyphs +//! +//! Order is not significant. An empty `fmt=` means the terminal recognizes +//! Glyph Protocol but currently advertises no payload formats. Clients must +//! ignore unknown format names. +//! +//! Any reply confirms support; no reply within a timeout means the +//! terminal does not implement the protocol. +//! +//! ## Query (`q`) +//! +//! Asks whether a codepoint is renderable and by whom. +//! +//! Request: `ESC _ 25a1 ; q ; cp=<hex> ESC \` +//! Response: `ESC _ 25a1 ; q ; cp=<hex> ; status=<list> ESC \` +//! +//! `status` is a comma-separated list of coverage names: +//! - empty — nothing renders this codepoint (tofu) +//! - `system` — a system font covers it +//! - `glossary` — a session registration covers it +//! - `system,glossary` — both; the registration shadows the system font +//! +//! Non-PUA codepoints can only report empty or `system`. Clients must ignore +//! unknown coverage names. +//! +//! ## Register (`r`) +//! +//! Registers a glyph outline at a Private Use Area codepoint. +//! +//! Request: +//! `ESC _ 25a1 ; r ; cp=<hex> [; fmt=glyf] [; reply=<0|1|2>] +//! [; upm=<int>] [; aw=<int>] [; lh=<int>] [; width=<1|2>] +//! [; size=<height|advance|contain|cover|stretch>] +//! [; align=<start|center|end>,<start|center|end|baseline>] +//! [; pad=<top>,<right>,<bottom>,<left>] ; <base64-payload> ESC \` +//! +//! Response: +//! `ESC _ 25a1 ; r ; cp=<hex> ; status=0 ESC \` +//! On error: `status=<nonzero> ; reason=<code>` +//! +//! Parameters: +//! - `cp` — target codepoint (hex). Must be in a PUA range: +//! U+E000–U+F8FF, U+F0000–U+FFFFD, or U+100000–U+10FFFD. +//! Non-PUA values are rejected with `reason=out_of_namespace`. +//! - `fmt` — payload format. Default `glyf`; `colrv0` and `colrv1` +//! are optional and advertised via the `s` reply. +//! - `reply` — response verbosity: +//! `1` (default) = success + failure replies +//! `2` = failure replies only (silent success) +//! `0` = no replies (fire-and-forget) +//! - `upm` — units-per-em for the coordinate space. Default 1000. +//! - `aw` — authored advance width in upm units. Default `upm`. +//! - `lh` — authored line height in upm units. Default `upm`. +//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. +//! This is authoritative for cursor advance, wrapping, and +//! selection geometry. +//! - `size` — scale policy. Default `height`. +//! - `align` — horizontal and vertical placement within the render span. +//! Default `center,center`. +//! - `pad` — fractional insets from the render span edges. Default +//! `0,0,0,0`; degenerate padding is treated as no padding. +//! - payload — base64-encoded payload for the selected `fmt`. +//! +//! The `glyf` subset accepted: +//! - Simple glyphs only (no composites). +//! - Standard flag encoding (on-curve, off-curve, x/y-short, repeat). +//! - No hinting instructions. +//! - Coordinates are in the `upm` space, Y-up, with `y=0` at the baseline; +//! the terminal scales and positions at render time using `aw`, `lh`, +//! `width`, `size`, `align`, and `pad`. +//! +//! `colrv0` and `colrv1` wrap OpenType `COLR`/`CPAL` data together with the +//! simple-glyph outlines they reference. `colrv0` uses layered flat colours; +//! `colrv1` uses the OpenType paint graph and may omit `CPAL` if it does not +//! reference palette indices. +//! +//! A second `r` on the same `cp` overwrites the previous registration. +//! `glyf` outlines render in the current foreground colour. +//! +//! ## Clear (`c`) +//! +//! Removes registrations. +//! +//! Single slot: `ESC _ 25a1 ; c ; cp=<hex> ESC \` +//! All slots: `ESC _ 25a1 ; c ESC \` +//! +//! The terminal acks with `status=0` even if the slot was already empty. +//! Clear replies do not echo `cp`. `cp` must be in a PUA range; non-PUA values return +//! `reason=out_of_namespace`. +//! +//! ## Glossary Capacity +//! +//! Each session holds at most 1024 registrations keyed by codepoint. +//! Registrations live for the session duration. A 1025th registration +//! evicts the oldest entry (FIFO). Sessions are isolated: two tabs may +//! independently register the same codepoint. +//! +//! ## Security: PUA-Only Restriction +//! +//! Registration is restricted to the three Unicode Private Use Areas to +//! prevent glyph-spoofing attacks. PUA codepoints never appear in normal +//! text (filenames, URLs, commands), so a registered glyph cannot alter +//! how real text is perceived. The cell buffer always stores the original +//! codepoint — copy/paste, search, and hyperlink detection return the +//! codepoint the application emitted, never the rendered glyph. +//! +//! Reference: <https://raw.githubusercontent.com/raphamorim/rio/779dba839dbb76c551f2efa852b82a2ed669101b/specs/glyph-protocol.md> + +const std = @import("std"); + +pub const request = @import("glyph/request.zig"); +pub const response = @import("glyph/response.zig"); +pub const execute = @import("glyph/execute.zig").execute; + +pub const CommandParser = request.CommandParser; +pub const Request = request.Request; +pub const Response = response.Response; +pub const Glossary = @import("glyph/Glossary.zig"); + +test { + std.testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md new file mode 100644 index 00000000..c81029c0 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md @@ -0,0 +1,9 @@ +# Glyph Protocol + +- The specification source of truth is: + <https://github.com/raphamorim/rio/blob/main/specs/glyph-protocol.md> +- A summary of the specification is available in + `src/terminal/apc/glyph.zig` at the top. +- Reference the specification whenever any changes are made to + this folder. Prefer the local specification over fetching the + latest, unless it is lacking information. diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig new file mode 100644 index 00000000..28458dbb --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig @@ -0,0 +1,444 @@ +/// Glossary is the per-terminal storage for Glyph Protocol +/// codepoints. We use the word Glossary to match up with the spec which +/// also uses this word. +const Glossary = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf; +const FontGlyph = @import("../../../font/Glyph.zig"); +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; + +const request = @import("request.zig"); +const RegisterReq = request.Request.Register; + +const DesignMetrics = FontGlyph.DesignMetrics; +const Constraint = FontGlyph.RenderOptions.Constraint; + +/// Maximum entries allowed in the glossary before eviction. +/// Defined by the specification. +pub const max_entries = 1024; + +/// An empty glossary with no registered glyphs. +pub const empty: Glossary = .{ .entries = .empty }; + +/// Errors that can occur while registering a glossary entry. +pub const RegisterError = Allocator.Error || error{OutOfNamespace}; + +/// Errors that can occur while clearing glossary entries. +pub const ClearError = error{OutOfNamespace}; + +/// The set of entries in the glossary keyed by the codepoint. +/// +/// The array hash map preserves insertion order and has O(N) +/// orderedRemove, so we use it as a FIFO too for eviction when +/// the glossary is full. Since the specification limits the protocol +/// to 1024 maximum entries, ordered removal should never be that +/// expensive. +/// +/// I'm also operating under the assumption that full glossaries +/// for a session will be rare, so the eviction cost shouldn't +/// happen regularly. +entries: std.AutoArrayHashMapUnmanaged(u21, Entry), + +/// Release all glyph entries and hash map storage owned by the glossary. +pub fn deinit(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.* = undefined; +} + +/// Register the given glyph entry. +/// +/// This will act according to the glyph specification +pub fn register( + self: *Glossary, + alloc: Allocator, + cp: u21, + entry: Entry, +) RegisterError!void { + // Validate codepoint according to spec. + if (!isPrivateUse(cp)) return error.OutOfNamespace; + + const gop = try self.entries.getOrPut(alloc, cp); + if (gop.found_existing) { + // Found an existing entry, we need to shift the FIFO so + // that this is now the most recent (at the end). This is + // O(N) but N is usually small and max N is bounded by the spec. + gop.value_ptr.*.deinit(alloc); + assert(self.entries.orderedRemove(cp)); + + // We already had enough capacity for this key before removing it, so + // reinserting the replacement cannot require another allocation. + self.entries.putAssumeCapacity(cp, entry); + return; + } + + // Array hash maps preserve insertion order so always immediately insert. + gop.value_ptr.* = entry; + + // Fast, typical path: we fit within the glossary, just return. + if (self.entries.count() <= max_entries) return; + + // Slow path: we need to evict. + self.entries.values()[0].deinit(alloc); + self.entries.orderedRemoveAt(0); +} + +/// Delete a single entry from the glossary. If the entry doesn't exist, +/// then this does nothing and is safe. +pub fn delete( + self: *Glossary, + alloc: Allocator, + cp: u21, +) ClearError!void { + if (!isPrivateUse(cp)) return error.OutOfNamespace; + const kv = self.entries.fetchOrderedRemove(cp) orelse return; + var entry = kv.value; + entry.deinit(alloc); +} + +/// Clear all entries from the glossary and free up any underlying +/// storage. +pub fn clearAndFree(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.entries = .empty; +} + +/// Contains returns true if the codepoint is covered by the glossary. +pub fn contains(self: *Glossary, cp: u21) bool { + return self.entries.contains(cp); +} + +/// A single glyph registration entry. +pub const Entry = struct { + /// Stored glyph payload variants. + pub const Glyph = union(enum) { + glyf: Glyf.Outline, + }; + + /// The glyph itself. The tagged union only has glyf right now but + /// will eventually expand to support COLR and maybe other formats. + /// These are stored as raw outlines; rasterization is delayed to + /// renderers. The outlines have been validated. + glyph: Glyph, + + /// Authored metrics for the glyph's design coordinate space. + design: DesignMetrics, + + /// Unicode cell width requested by the registration. + width: request.Width, + + /// Normalized scale, alignment, and padding behavior for rasterization. + constraint: Constraint, + + /// Errors that can occur while constructing a glossary entry from a + /// register request. + pub const InitError = RegisterReq.DecodeError || error{ + /// The register request is missing a required option or has an invalid + /// explicitly-provided option value. + InvalidOptions, + + /// The requested payload format is not supported by this glossary. + UnsupportedFormat, + }; + + /// Initialize a glossary entry from a register request. + /// + /// This validates the request fields needed to construct the entry, + /// decodes the base64 glyph payload, and stores the decoded outline. The + /// returned entry owns decoded glyph memory and must be released with + /// `deinit`. + pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry { + // Validate format + const fmt = req.get(.fmt) orelse return error.InvalidOptions; + const design: DesignMetrics = .{ + .units_per_em = req.get(.upm) orelse return error.InvalidOptions, + .advance_width = req.get(.aw) orelse return error.InvalidOptions, + .line_height = req.get(.lh) orelse return error.InvalidOptions, + }; + if (design.units_per_em == 0 or + design.advance_width == 0 or + design.line_height == 0) return error.InvalidOptions; + const width = req.get(.width) orelse return error.InvalidOptions; + + // Get our constraints + const constraint = try constraintFromRegister(req); + + // Decode the payload into some usable glyph format for + // future rasterization. + const glyph: Glyph = switch (fmt) { + .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) }, + .colrv0, .colrv1 => return error.UnsupportedFormat, + }; + + // No more errors, since we never do glyph cleanup above. + errdefer comptime unreachable; + + return .{ + .glyph = glyph, + .design = design, + .width = width, + .constraint = constraint, + }; + } + + /// Release memory owned by this entry. + pub fn deinit(self: *Entry, alloc: Allocator) void { + switch (self.glyph) { + .glyf => |*outline| outline.deinit(alloc), + } + self.* = undefined; + } + + /// Return the renderer constraint for a register request. + /// + /// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of + /// the authored extent and render span. This function is the single + /// normalization point for how protocol sizing choices map to the + /// renderer-neutral constraint stored here. + fn constraintFromRegister( + req: RegisterReq, + ) error{InvalidOptions}!Constraint { + // Register.get applies the Glyph Protocol §6.1 defaults when options + // are omitted: size=height, align=center,center, and pad=0,0,0,0. + const size = req.get(.size) orelse return error.InvalidOptions; + const alignment = req.get(.@"align") orelse return error.InvalidOptions; + const pad = req.get(.pad) orelse return error.InvalidOptions; + + return .{ + .size = switch (size) { + // The rasterizer's base transform already maps the design em + // to the cell height. That is the closest existing behavior to + // the protocol's default height-driven mode. + .height => .none, + // There is no width-driven, aspect-preserving constraint mode + // today. Leave the base transform intact rather than forcing a + // fit/contain policy that would unexpectedly prevent overflow. + .advance => .none, + // Constraint.cover currently scales preserving aspect ratio to + // the available bounds, which is the best existing match for + // the protocol's contain mode. + .contain => .cover, + // There is no true protocol-cover equivalent that chooses the + // larger axis scale, so use the nearest named renderer policy. + .cover => .cover, + .stretch => .stretch, + }, + .align_horizontal = switch (alignment.horizontal) { + .start => .start, + .center => .center, + .end => .end, + }, + .align_vertical = switch (alignment.vertical) { + .start => .start, + .center => .center, + .end => .end, + // The current constraint API has no baseline alignment mode. + // Start is the closest stable default because the glyf + // rasterizer's coordinate model already treats y=0 as the + // baseline/bottom before constraints are applied. + .baseline => .start, + }, + .pad_top = pad.top, + .pad_right = pad.right, + .pad_bottom = pad.bottom, + .pad_left = pad.left, + }; + } +}; + +/// Return true if `cp` is in one of the Unicode Private Use Areas. +fn isPrivateUse(cp: u21) bool { + return (cp >= 0xE000 and cp <= 0xF8FF) or + (cp >= 0xF0000 and cp <= 0xFFFFD) or + (cp >= 0x100000 and cp <= 0x10FFFD); +} + +fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq { + const raw = try alloc.dupe(u8, data); + errdefer alloc.free(raw); + + const req = try request.Request.parse(alloc, raw); + switch (req) { + .register => |reg| return reg, + else => unreachable, + } +} + +// Base64-encoded glyf payload from the "glyf: decode triangle" test in +// font/opentype/glyf.zig. This is a real simple-glyph record with one contour +// and three on-curve points. +const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA="; + +fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq { + const data = try std.fmt.allocPrint( + alloc, + "r;cp={x};upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;{s}", + .{ cp, test_triangle_glyf_payload }, + ); + errdefer alloc.free(data); + + const req = try request.Request.parse(alloc, data); + switch (req) { + .register => |reg| return reg, + else => unreachable, + } +} + +fn testRegisterEntry(alloc: Allocator, cp: u21) !Entry { + const req = try testRegisterReq(alloc, cp); + defer alloc.free(req.raw); + return try Entry.init(alloc, req); +} + +test "Entry init decodes glyf payload and applies register fields" { + const testing = std.testing; + const alloc = testing.allocator; + + const req = try testRegisterReq(alloc, 0xE000); + defer alloc.free(req.raw); + + var entry = try Entry.init(alloc, req); + defer entry.deinit(alloc); + + try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em); + try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); + try testing.expectEqual(@as(u32, 1536), entry.design.line_height); + try testing.expectEqual(request.Width.wide, entry.width); + try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size); + try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal); + try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical); + try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); + try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right); + try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom); + try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left); + + try testing.expectEqual(@as(usize, 3), entry.glyph.glyf.points.len); + try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len); +} + +test "Entry init rejects invalid register payload" { + const testing = std.testing; + const alloc = testing.allocator; + + const req = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%"); + defer alloc.free(req.raw); + + try testing.expectError(error.MalformedPayload, Entry.init(alloc, req)); +} + +test "Glossary register overwrites and moves entry to newest position" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + + try testing.expectEqual(@as(usize, 2), glossary.entries.count()); + try testing.expectEqual(@as(u21, 0xE001), glossary.entries.keys()[0]); + try testing.expectEqual(@as(u21, 0xE000), glossary.entries.keys()[1]); +} + +test "Glossary register evicts oldest entry" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + for (0..max_entries + 1) |i| { + const cp: u21 = @intCast(0xE000 + i); + try glossary.register(alloc, cp, try testRegisterEntry(alloc, cp)); + } + + try testing.expectEqual(@as(usize, max_entries), glossary.entries.count()); + try testing.expect(!glossary.entries.contains(0xE000)); + try testing.expect(glossary.entries.contains(0xE001)); + try testing.expect(glossary.entries.contains(0xE000 + max_entries)); +} + +test "Glossary register rejects non-PUA codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var entry = try testRegisterEntry(alloc, 0xE000); + defer entry.deinit(alloc); + try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry)); +} + +test "Glossary delete removes one PUA slot and ignores empty PUA slot" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + + try glossary.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(glossary.contains(0xE001)); + + try glossary.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE001)); +} + +test "Glossary delete rejects non-PUA codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try testing.expectError(error.OutOfNamespace, glossary.delete(alloc, 'A')); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE000)); +} + +test "Glossary clearAndFree removes all slots and remains reusable" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + + glossary.clearAndFree(alloc); + try testing.expectEqual(@as(usize, 0), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); + + try glossary.register(alloc, 0xE002, try testRegisterEntry(alloc, 0xE002)); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE002)); +} + +test "Glossary contains reports registered slots" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try testing.expect(!glossary.contains(0xE000)); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try testing.expect(glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig new file mode 100644 index 00000000..059029a6 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig @@ -0,0 +1,367 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const request = @import("request.zig"); +const response = @import("response.zig"); +const Glossary = @import("Glossary.zig"); +const Request = request.Request; +const Response = response.Response; + +const log = std.log.scoped(.glyph); + +/// Payload formats we support. Hardcoded because the support is +/// fixed. +pub const supported_formats: response.Response.Support.Formats = .{ + .glyf = true, +}; + +/// Execute a Glyph protocol request against the given state. +/// +/// This will never fail, but the response may indiciate an error and +/// the terminal state may not be updated to reflect the command. This will +/// never put the terminal in a corrupt or non-recoverable state. +/// +/// For example, allocation errors can happen, but they're wrapped up in +/// an out of memory response. +/// +/// Query responses only report glossary coverage. Callers that can determine +/// system font coverage must update the returned query response before sending +/// it to the client. +pub fn execute( + alloc: Allocator, + glossary: *Glossary, + req: *const Request, +) ?Response { + log.debug("executing glyph protocol request: {t}", .{req.*}); + return switch (req.*) { + .support => .{ .support = .{ .fmt = supported_formats } }, + .query => |qry| query(glossary, qry), + .register => |reg| register(alloc, glossary, reg), + .clear => |clr| clear(alloc, glossary, clr), + }; +} + +fn query( + glossary: *Glossary, + qry: Request.Query, +) ?Response { + const cp = qry.get(.cp) orelse return null; + return .{ .query = .{ + .cp = cp, + .status = .{ + .glossary = glossary.contains(cp), + }, + } }; +} + +fn register( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) ?Response { + const reply = reg.get(.reply) orelse .all; + const cp = registerFallible(alloc, glossary, reg) catch |err| return switch (reply) { + .none => null, + .all, .failures => .{ .register = .{ + .cp = reg.get(.cp) orelse 0, + .status = .err, + .reason = switch (err) { + error.OutOfMemory => .{ .other = "out_of_memory" }, + error.OutOfNamespace => .out_of_namespace, + error.PayloadTooLarge => .payload_too_large, + error.MalformedPayload => .malformed_payload, + error.CompositeUnsupported => .composite_unsupported, + error.HintingUnsupported => .hinting_unsupported, + error.InvalidOptions, + error.UnsupportedFormat, + => .malformed_payload, + }, + } }, + }; + + return switch (reply) { + .none, .failures => null, + .all => .{ .register = .{ .cp = cp } }, + }; +} + +fn registerFallible( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) (Glossary.Entry.InitError || Glossary.RegisterError)!u21 { + const cp = reg.get(.cp) orelse + return error.MalformedPayload; + + var entry = try Glossary.Entry.init(alloc, reg); + errdefer entry.deinit(alloc); + + try glossary.register(alloc, cp, entry); + return cp; +} + +fn clear( + alloc: Allocator, + glossary: *Glossary, + clr: Request.Clear, +) ?Response { + if (clr.get(.cp)) |cp| { + glossary.delete(alloc, cp) catch |err| return .{ .clear = .{ + .status = .err, + .reason = switch (err) { + error.OutOfNamespace => "out_of_namespace", + }, + } }; + } else if (clr.has(.cp)) { + return .{ .clear = .{ + .status = .err, + .reason = "malformed_payload", + } }; + } else { + glossary.clearAndFree(alloc); + } + + return .{ .clear = .{} }; +} + +fn testParse(alloc: Allocator, data: []const u8) !Request { + var parser = request.CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +fn testExecute(alloc: Allocator, glossary: *Glossary, req: *const Request) ?Response { + return execute(alloc, glossary, req); +} + +test "execute register stores glyph and returns success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ .cp = 0xE0A0 }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply failures suppresses success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply none suppresses failure" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); + try testing.expect(!glossary.contains('A')); +} + +test "execute register rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 'A', + .status = .err, + .reason = .out_of_namespace, + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains('A')); +} + +test "execute register reports malformed payload" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 0xE0A0, + .status = .err, + .reason = .malformed_payload, + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); +} + +test "execute clear removes all glyphs" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(!glossary.contains(0xE0A1)); +} + +test "execute clear removes one glyph" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); +} + +test "execute clear rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "c;cp=41"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "out_of_namespace", + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute clear rejects malformed cp without clearing glossary" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var req = try testParse(alloc, data); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "malformed_payload", + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); + } +} + +test "execute query reports no coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{}, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query reports glossary coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg.deinit(alloc); + _ = testExecute(alloc, &glossary, ®); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{ .glossary = true }, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query without cp returns no response" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;foo=bar"); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig new file mode 100644 index 00000000..71a2dd4b --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig @@ -0,0 +1,855 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; + +/// Maximum decoded glyph payload size accepted by the protocol. +/// This is documented in the spec. +const max_payload_size = 64 * 1024; // 64 KiB + +/// Stateful parser for a single glyph APC payload after the `25a1;` prefix. +pub const CommandParser = struct { + alloc: Allocator, + data: std.ArrayList(u8) = .empty, + + /// Maximum bytes the data payload can buffer. This is to prevent + /// malicious input from causing us to allocate too much memory. + max_bytes: usize, + + pub const Error = Allocator.Error || error{InvalidFormat}; + + /// Create a glyph APC parser that buffers the raw command bytes. + pub fn init(alloc: Allocator, max_bytes: usize) CommandParser { + return .{ .alloc = alloc, .max_bytes = max_bytes }; + } + + /// Release any buffered command bytes owned by the parser. + pub fn deinit(self: *CommandParser) void { + self.data.deinit(self.alloc); + } + + /// Append one more byte of APC payload to the buffered command. + pub fn feed(self: *CommandParser, byte: u8) Allocator.Error!void { + if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; + try self.data.append(self.alloc, byte); + } + + /// Append a slice of APC payload bytes to the buffered command. + /// Equivalent to calling feed for each byte, but appends in bulk. + pub fn feedSlice(self: *CommandParser, bytes: []const u8) Allocator.Error!void { + if (self.data.items.len + bytes.len > self.max_bytes) return error.OutOfMemory; + try self.data.appendSlice(self.alloc, bytes); + } + + /// Finish parsing and return an owned request that can outlive the parser. + pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request { + // Normalize bare single-byte verbs like `s` into `s;` so the parsed + // command always has the standard `verb;...` layout. + if (self.data.items.len == 1) try self.data.append(self.alloc, ';'); + + const raw = try self.data.toOwnedSlice(alloc); + + // Ownership of the buffered bytes has moved to `raw`, so clear the + // array list before we build the final command value. + self.data = .empty; + errdefer alloc.free(raw); + return try Request.parse(alloc, raw); + } +}; + +/// Parsed glyph APC request with the verb classified eagerly. +pub const Request = union(enum) { + /// Support query (bare `s` verb, no options). + support, + + /// Codepoint coverage query. + query: Query, + + /// Glyph registration request. + register: Register, + + /// Registration clear request. + clear: Clear, + + /// Query verb payload with lazily-decoded options. + pub const Query = struct { + raw: []const u8, + + /// Initialize a query command from owned raw command bytes. + pub fn init(raw: []const u8) Query { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph query request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a query option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this query option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a query option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a query option on demand. + pub fn get(self: Query, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid query command. + fn rawOptions(self: Query) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'q'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Register verb payload with lazily-decoded options and optional base64 data. + pub const Register = struct { + raw: []const u8, + payload_idx: usize, + + /// Initialize a register command from owned raw command bytes. + pub fn init(raw: []const u8) ?Register { + if (raw.len < 2) return null; + if (raw[0] != 'r') return null; + if (raw[1] != ';') return null; + const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';') orelse return null; + if (payload_idx <= 1) return null; + + return .{ + .raw = raw, + .payload_idx = payload_idx, + }; + } + + /// Options recognized for the glyph register verb. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Glyph payload format. + fmt, + + /// Requested reply verbosity for registration. + reply, + + /// Units-per-em for the glyph coordinate system. + upm, + + /// Authored advance width in units-per-em units. + aw, + + /// Authored line height in units-per-em units. + lh, + + /// Unicode cell width for terminal layout. + width, + + /// Glyph scale policy. + size, + + /// Glyph placement within the render span. + @"align", + + /// Fractional insets from the render span edges. + pad, + + /// Return the decoded Zig type for a register option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + .fmt => Format, + .reply => Reply, + .upm => u32, + .aw => u32, + .lh => u32, + .width => Width, + .size => Size, + .@"align" => Align, + .pad => Pad, + }; + } + + /// Return the protocol default value for this option, if any. + pub fn default(comptime self: Option) ?self.Type() { + return switch (self) { + .cp => null, + .fmt => .glyf, + .reply => .all, + .upm => 1000, + .aw => null, + .lh => null, + .width => .narrow, + .size => .height, + .@"align" => .{}, + .pad => .{}, + }; + } + + /// Return the wire-format option key for this register option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a register option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + .fmt => Format.init(value), + .reply => Reply.init(value) orelse .all, + .upm => std.fmt.parseInt(u32, value, 10) catch null, + .aw => std.fmt.parseInt(u32, value, 10) catch null, + .lh => std.fmt.parseInt(u32, value, 10) catch null, + .width => Width.init(value), + .size => Size.init(value), + .@"align" => Align.init(value), + .pad => Pad.init(value), + }; + } + }; + + /// Lazily decode a register option on demand, applying protocol + /// defaults when the option is omitted. + pub fn get(self: Register, comptime option: Option) ?option.Type() { + const raw = self.rawOptions(); + if (optionValue(raw, option.key()) == null) { + return switch (option) { + .aw, .lh => self.get(.upm), + else => option.default(), + }; + } + return option.read(raw); + } + + /// Return the base64 payload carried by a register request. + /// + /// If no payload is present, this returns an empty slice. The returned + /// bytes may still be invalid base64; this function only exposes the raw + /// payload segment and does not validate or decode it. + pub fn payload(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + return if (self.payload_idx == self.raw.len) + "" + else + self.raw[self.payload_idx + 1 ..]; + } + + /// Errors that can occur while decoding a register glyph payload. + pub const DecodeError = Allocator.Error || error{ + /// The decoded payload exceeds the protocol limit. + PayloadTooLarge, + + /// The payload could not be decoded or parsed as the declared format. + MalformedPayload, + + /// The glyf payload is composite, which the protocol forbids. + CompositeUnsupported, + + /// The glyf payload contains hinting instructions, which the + /// protocol forbids. + HintingUnsupported, + }; + + /// Decode this request's base64 glyf payload into an owned outline. + pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline { + // Prep base64 decoding, initial validation. + const Decoder = std.base64.standard.Decoder; + const payload_bytes = self.payload(); + const size = Decoder.calcSizeForSlice(payload_bytes) catch + return error.MalformedPayload; + if (size > max_payload_size) return error.PayloadTooLarge; + + // Max payload size is reasonable for stack and its likely + // we'll have stack space. We don't use much stack space in + // the future function calls either, so try a stack allocator + // here and fallback to heap as necessary. + var data_stack = std.heap.stackFallback( + max_payload_size, + alloc, + ); + const data_alloc = data_stack.get(); + const data = try data_alloc.alloc(u8, size); + defer data_alloc.free(data); + + // Base64 decode + Decoder.decode(data, payload_bytes) catch + return error.MalformedPayload; + + // Glyf.Entry borrows from `data`, but only for the duration of the + // decode call below. Glyf.Entry.decode returns an owned Outline, so + // it is safe to free `data` before returning that outline. + const glyf_entry = Glyf.Entry.init(data) catch return error.MalformedPayload; + return glyf_entry.decode(alloc) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + // Unsupported fields + error.CompositeNotSupported => error.CompositeUnsupported, + error.InstructionsNotSupported => error.HintingUnsupported, + // Various semantic issues + error.EndOfStream, + error.EndPointsOutOfOrder, + error.TooManyPoints, + error.CoordinateOverflow, + => error.MalformedPayload, + }; + } + + /// Return the raw option portion of a valid register command. + fn rawOptions(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + assert(self.payload_idx >= 2); + assert(self.payload_idx <= self.raw.len); + return self.raw[2..self.payload_idx]; + } + }; + + /// Clear verb payload with lazily-decoded options. + pub const Clear = struct { + raw: []const u8, + + /// Initialize a clear command from owned raw command bytes. + pub fn init(raw: []const u8) Clear { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph clear request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a clear option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this clear option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a clear option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + + /// Return whether the option is present in the raw option string, + /// independent of whether its value can be decoded. + pub fn present(comptime self: Option, raw: []const u8) bool { + return optionValue(raw, self.key()) != null; + } + }; + + /// Lazily decode a clear option on demand. + pub fn get(self: Clear, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return whether a clear option was provided, even if malformed. + pub fn has(self: Clear, comptime option: Option) bool { + return option.present(self.rawOptions()); + } + + /// Return the raw option portion of a valid clear command. + fn rawOptions(self: Clear) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'c'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Parse an owned glyph APC payload into its eagerly-classified request + /// form. + /// + /// The raw format here is strict on its requirements to avoid + /// edge cases: it must contain the request AND the request must + /// end in a semicolon (even if there are no options). The spec itself + /// does not require this but we artificially insert it in our parser + /// to simplify parsing later. + pub fn parse(alloc: Allocator, raw: []const u8) error{InvalidFormat}!Request { + if (raw.len < 2) return error.InvalidFormat; + if (raw[1] != ';') return error.InvalidFormat; + + return switch (raw[0]) { + 's' => { + alloc.free(raw); + return .support; + }, + 'q' => .{ .query = .init(raw) }, + 'r' => .{ .register = Register.init(raw) orelse return error.InvalidFormat }, + 'c' => .{ .clear = .init(raw) }, + else => error.InvalidFormat, + }; + } + + /// Free the raw bytes retained by any request variant. + pub fn deinit(self: *Request, alloc: Allocator) void { + switch (self.*) { + .support => {}, + inline else => |*cmd| if (cmd.raw.len > 0) alloc.free(cmd.raw), + } + } +}; + +/// Glyph payload formats named by the protocol. +pub const Format = enum { + /// TrueType simple glyph outline data. + glyf, + + /// OpenType COLR version 0 layered color glyph data. + colrv0, + + /// OpenType COLR version 1 paint graph glyph data. + colrv1, + + /// Parse a glyph payload format name. + pub fn init(value: []const u8) ?Format { + return std.meta.stringToEnum(Format, value); + } +}; + +/// Register command reply verbosity. +pub const Reply = enum(u2) { + /// Suppress both success and failure replies. + none = 0, + + /// Emit replies for both success and failure cases. + all = 1, + + /// Emit replies only for failure cases. + failures = 2, + + /// Parse the register command reply mode from its single-digit encoding. + pub fn init(value: []const u8) ?Reply { + if (value.len != 1) return null; + return switch (value[0]) { + '0' => .none, + '1' => .all, + '2' => .failures, + else => null, + }; + } +}; + +/// Register command width override for terminal layout. +pub const Width = enum(u2) { + /// One terminal cell. + narrow = 1, + + /// Two terminal cells. + wide = 2, + + /// Parse the register command width from its single-digit encoding. + pub fn init(value: []const u8) ?Width { + if (value.len != 1) return null; + return switch (value[0]) { + '1' => .narrow, + '2' => .wide, + else => null, + }; + } +}; + +/// Register command glyph scale policy. +pub const Size = enum { + height, + advance, + contain, + cover, + stretch, + + /// Parse a glyph scale policy name. + pub fn init(value: []const u8) ?Size { + return std.meta.stringToEnum(Size, value); + } +}; + +/// Register command glyph placement within the render span. +pub const Align = struct { + horizontal: Horizontal = .center, + vertical: Vertical = .center, + + pub const Horizontal = enum { + start, + center, + end, + + fn init(value: []const u8) ?Horizontal { + return std.meta.stringToEnum(Horizontal, value); + } + }; + + pub const Vertical = enum { + start, + center, + end, + baseline, + + fn init(value: []const u8) ?Vertical { + return std.meta.stringToEnum(Vertical, value); + } + }; + + /// Parse an align value in `<horizontal>,<vertical>` form. + pub fn init(value: []const u8) ?Align { + var it = std.mem.splitScalar(u8, value, ','); + const horizontal = Horizontal.init(it.next() orelse return null) orelse return null; + const vertical = Vertical.init(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + return .{ + .horizontal = horizontal, + .vertical = vertical, + }; + } +}; + +/// Register command fractional insets from the render span edges. +pub const Pad = struct { + top: f64 = 0, + right: f64 = 0, + bottom: f64 = 0, + left: f64 = 0, + + /// Parse a pad value in `<top>,<right>,<bottom>,<left>` form. + pub fn init(value: []const u8) ?Pad { + var it = std.mem.splitScalar(u8, value, ','); + const top = parseFraction(it.next() orelse return null) orelse return null; + const right = parseFraction(it.next() orelse return null) orelse return null; + const bottom = parseFraction(it.next() orelse return null) orelse return null; + const left = parseFraction(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + // Glyph Protocol §8.5.2: "If `l + r ≥ 1` or `t + b ≥ 1` + // the terminal MUST treat the request as if `pad=0,0,0,0`." + if (left + right >= 1 or top + bottom >= 1) return .{}; + + return .{ + .top = top, + .right = right, + .bottom = bottom, + .left = left, + }; + } + + /// Parse one pad component from the spec's `0.0`–`1.0` fractional range. + /// Top/bottom fractions are relative to cell height; left/right fractions + /// are relative to render span width. + fn parseFraction(value: []const u8) ?f64 { + const result = std.fmt.parseFloat(f64, value) catch return null; + if (!(result >= 0 and result <= 1)) return null; + return result; + } +}; + +/// Find the last occurrence of `key=value` for a lazily-parsed option list. +fn optionValue(raw: []const u8, comptime key: []const u8) ?[]const u8 { + var remaining = raw; + var result: ?[]const u8 = null; + while (remaining.len > 0) { + // Options are semicolon-delimited, so each loop peels off one segment + // and checks whether it matches the requested key. + const len = std.mem.indexOfScalar(u8, remaining, ';') orelse remaining.len; + const full = remaining[0..len]; + + if (std.mem.indexOfScalar(u8, full, '=')) |eql_idx| { + if (std.mem.eql(u8, full[0..eql_idx], key)) { + result = full[eql_idx + 1 ..]; + } + } + + if (len == remaining.len) break; + remaining = remaining[len + 1 ..]; + } + + return result; +} + +fn testParse(alloc: Allocator, data: []const u8) CommandParser.Error!Request { + var parser = CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +test "support command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "s"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .support); +} + +test "query command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "q;cp=E0A0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .query); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.query.get(.cp).?); +} + +test "register command with payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;upm=1000;reply=2;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register command with sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;upm=2048;aw=1024;lh=1536;width=2;size=contain;align=end,baseline;pad=0.1,0.2,0.3,0.4;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1024), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1536), cmd.register.get(.lh).?); + try testing.expectEqual(Width.wide, cmd.register.get(.width).?); + try testing.expectEqual(Size.contain, cmd.register.get(.size).?); + try testing.expectEqual(Align{ + .horizontal = .end, + .vertical = .baseline, + }, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{ + .top = 0.1, + .right = 0.2, + .bottom = 0.3, + .left = 0.4, + }, cmd.register.get(.pad).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register option defaults" { + const testing = std.testing; + const Option = Request.Register.Option; + + try testing.expect(Option.cp.default() == null); + try testing.expectEqual(Format.glyf, Option.fmt.default().?); + try testing.expectEqual(@as(u32, 1000), Option.upm.default().?); + try testing.expect(Option.aw.default() == null); + try testing.expect(Option.lh.default() == null); + try testing.expectEqual(Width.narrow, Option.width.default().?); + try testing.expectEqual(Size.height, Option.size.default().?); + try testing.expectEqual(Align{}, Option.@"align".default().?); + try testing.expectEqual(Pad{}, Option.pad.default().?); + try testing.expectEqual(Reply.all, Option.reply.default().?); +} + +test "register command defaults" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.lh).?); + try testing.expectEqual(Width.narrow, cmd.register.get(.width).?); + try testing.expectEqual(Size.height, cmd.register.get(.size).?); + try testing.expectEqual(Align{}, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command aw and lh default to upm" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;upm=2048;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.lh).?); +} + +test "register command invalid sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;width=3;size=invalid;align=center,middle;pad=0,1.2,0,0;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expect(cmd.register.get(.width) == null); + try testing.expect(cmd.register.get(.size) == null); + try testing.expect(cmd.register.get(.@"align") == null); + try testing.expect(cmd.register.get(.pad) == null); +} + +test "register command degenerate padding defaults to no padding" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;pad=0.4,0.2,0.6,0.1;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); +} + +test "register command invalid reply falls back to reply=1" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=9;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command duplicate options use the last value" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=1;reply=2;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); +} + +test "register command with invalid payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;%%%not-base64%%%", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); +} + +test "register command rejects missing payload separator" { + const testing = std.testing; + + for ([_][]const u8{ "r", "r;cp=e0a0", "r;foo" }) |data| { + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, data), + ); + } +} + +test "register decodes glyf payload" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer cmd.deinit(testing.allocator); + + var outline = try cmd.register.decodeGlyfPayload(testing.allocator); + defer outline.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "register rejects malformed glyf payload" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;%%%not-base64%%%"); + defer cmd.deinit(testing.allocator); + + try testing.expectError(error.MalformedPayload, cmd.register.decodeGlyfPayload(testing.allocator)); +} + +test "register response without payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=E0A0;status=4;reason=out_of_namespace", + ); + defer cmd.deinit(testing.allocator); + + // Register parsing is request-only, so the final segment is always treated + // as payload rather than as a response field. + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqualStrings("reason=out_of_namespace", cmd.register.payload()); +} + +test "clear command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "c;cp=e0a0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); +} + +test "clear command tracks malformed cp presence" { + const testing = std.testing; + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var cmd = try testParse(testing.allocator, data); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expect(cmd.clear.get(.cp) == null); + } +} + +test "invalid command" { + const testing = std.testing; + + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, "x"), + ); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig new file mode 100644 index 00000000..9e40f0ea --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig @@ -0,0 +1,346 @@ +const std = @import("std"); + +/// Query response coverage state for a codepoint. +pub const Coverage = packed struct(u2) { + /// A system font covers the codepoint. + system: bool = false, + + /// A session glyph registration covers the codepoint. + glossary: bool = false, + + /// No system font or registered glyph covers the codepoint. + pub const free: Coverage = .{}; + + /// Parse the query response coverage list from its comma-separated form. + /// Unknown coverage names are ignored for forward compatibility. + pub fn init(value: []const u8) ?Coverage { + var result: Coverage = .free; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |name| { + if (std.mem.eql(u8, name, "system")) { + result.system = true; + } else if (std.mem.eql(u8, name, "glossary")) { + result.glossary = true; + } + } + + return result; + } +}; + +/// Response to a glyph APC request, formatted for the wire protocol. +pub const Response = union(enum) { + /// Recommended fixed buffer size for formatting a Glyph Protocol response. + /// + /// Glyph Protocol responses contain only framing plus bounded scalar fields: + /// a u21 codepoint as hex, a decimal u8 status, a small fixed set of + /// supported format names, coverage names, and the reason names produced by + /// the executor. 1024 bytes is therefore far above the longest response we + /// can emit today, while still being small enough for stack allocation in + /// stream handlers. If callers construct responses with arbitrary `.other` + /// or clear reason strings, they must ensure those strings fit or handle the + /// writer error from `formatWire`. + pub const max_wire_bytes = 1024; + + /// Support query response listing supported payload formats. + support: Support, + + /// Codepoint coverage query response. + query: Query, + + /// Glyph registration response (success or error). + register: Register, + + /// Registration clear response. + clear: Clear, + + /// Support query response fields. + pub const Support = struct { + /// Supported payload formats. + fmt: Formats, + + pub const Formats = packed struct(u8) { + /// TrueType simple glyph outlines (required in v1). + glyf: bool = false, + + /// COLR v0 layered flat-colour glyphs. + colrv0: bool = false, + + /// COLR v1 paint-graph glyphs. + colrv1: bool = false, + + _padding: u5 = 0, + }; + }; + + /// Codepoint query response fields. + pub const Query = struct { + /// The queried codepoint. + cp: u21, + + /// Coverage status for the codepoint. + status: Coverage, + }; + + /// Register response fields. + pub const Register = struct { + /// The target codepoint of the registration. + cp: u21, + + /// Result status of the registration encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?Reason = null, + + /// Register error reason codes defined by Glyph Protocol §6.2. + pub const Reason = union(enum) { + /// `cp` is not in any PUA range. + out_of_namespace, + + /// Payload contains composite glyphs. + composite_unsupported, + + /// Payload contains hinting instructions. + hinting_unsupported, + + /// Payload failed to parse as the declared `fmt`. + malformed_payload, + + /// Payload exceeds 64 KiB after base64 decoding. + payload_too_large, + + /// A reason code not known by this version of Ghostty. + other: []const u8, + + /// Return the wire-format reason name. + pub fn name(self: Reason) []const u8 { + return switch (self) { + .out_of_namespace => "out_of_namespace", + .composite_unsupported => "composite_unsupported", + .hinting_unsupported => "hinting_unsupported", + .malformed_payload => "malformed_payload", + .payload_too_large => "payload_too_large", + .other => |value| value, + }; + } + }; + }; + + /// Clear response fields. + pub const Clear = struct { + /// Result status of the clear operation encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?[]const u8 = null, + }; + + /// Status code for register and clear responses. + pub const Status = enum(u8) { + /// The operation completed successfully. + ok = 0, + + /// A generic or unspecified error occurred. + err = 1, + + _, + }; + + /// Write the response in the glyph APC wire format to `writer`. + /// + /// The framing is: `ESC _ 25a1 ; <verb> ; <key=value>* ESC \` + pub fn formatWire( + self: Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.writeAll("\x1b_25a1;"); + switch (self) { + .support => |r| { + // From the spec: + // Order is not significant; clients MUST treat the value as a + // set. An empty fmt= value means the terminal recognises + // Glyph Protocol but currently advertises no payload formats + // — every r will be rejected. Clients MUST ignore names they + // do not recognise rather than failing the reply, so future + // format names are forward- compatible. + try writer.writeAll("s;fmt="); + var first = true; + if (r.fmt.glyf) { + first = false; + try writer.writeAll("glyf"); + } + if (r.fmt.colrv0) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv0"); + } + if (r.fmt.colrv1) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv1"); + } + }, + .query => |r| { + // status is a comma-separated list of coverage names — the + // set of sources that can render cp in this session. Order is + // not significant; clients MUST treat the value as a set. + try writer.print("q;cp={x};status=", .{r.cp}); + var first = true; + if (r.status.system) { + first = false; + try writer.writeAll("system"); + } + if (r.status.glossary) { + if (!first) try writer.writeByte(','); + try writer.writeAll("glossary"); + } + }, + .register => |r| { + try writer.print("r;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) }); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason.name()); + } + }, + .clear => |r| { + try writer.print("c;status={d}", .{@intFromEnum(r.status)}); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason); + } + }, + } + try writer.writeAll("\x1b\\"); + } +}; + +test "support formats default to no advertised formats" { + const testing = std.testing; + const Formats = Response.Support.Formats; + + try testing.expectEqual(@as(u8, 0), @as(u8, @bitCast(Formats{}))); +} + +test "response support formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{ .glyf = true, .colrv0 = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=glyf,colrv0\x1b\\", writer.buffered()); +} + +test "response support formatWire with no formats" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=\x1b\\", writer.buffered()); +} + +test "response query formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{ .system = true, .glossary = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=system,glossary\x1b\\", writer.buffered()); +} + +test "response query formatWire with no coverage" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=\x1b\\", writer.buffered()); +} + +test "coverage parses comma-separated names" { + const testing = std.testing; + + try testing.expectEqual(Coverage{}, Coverage.init("").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system").?); + try testing.expectEqual(Coverage{ .glossary = true }, Coverage.init("glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("system,glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("glossary,system").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system,future").?); + try testing.expectEqual(Coverage{}, Coverage.init("future").?); +} + +test "response register success formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0 } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=0\x1b\\", writer.buffered()); +} + +test "response register error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = .err, .reason = .out_of_namespace } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} + +test "response register arbitrary status formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = @enumFromInt(37), .reason = .payload_too_large } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=37;reason=payload_too_large\x1b\\", writer.buffered()); +} + +test "register reason names" { + const testing = std.testing; + const Reason = Response.Register.Reason; + + try testing.expectEqualStrings("out_of_namespace", (Reason{ .out_of_namespace = {} }).name()); + try testing.expectEqualStrings("composite_unsupported", (Reason{ .composite_unsupported = {} }).name()); + try testing.expectEqualStrings("hinting_unsupported", (Reason{ .hinting_unsupported = {} }).name()); + try testing.expectEqualStrings("malformed_payload", (Reason{ .malformed_payload = {} }).name()); + try testing.expectEqualStrings("payload_too_large", (Reason{ .payload_too_large = {} }).name()); + try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name()); +} + +test "response clear formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{} }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=0\x1b\\", writer.buffered()); +} + +test "response clear error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{ .status = .err, .reason = "out_of_namespace" } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} diff --git a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig index deeecf55..787f0ace 100644 --- a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig +++ b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig @@ -219,7 +219,13 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { const bitmap_start = 0; const bitmap_end = @sizeOf(u64) * bitmap_count; const chunks_start = alignForward(usize, bitmap_end, @alignOf(u8)); - const chunks_end = chunks_start + (aligned_cap * chunk_size); + + // The chunks region must be exactly the bytes addressable by + // the bitmaps: one chunk per bit of every bitmap. Anything more + // is unreachable waste, while anything less would let alloc + // hand out memory beyond the region, since init marks every + // bitmap bit as free. + const chunks_end = chunks_start + (aligned_chunk_count * chunk_size); const total_size = chunks_end; return Layout{ @@ -452,6 +458,79 @@ test "BitmapAllocator layout" { try testing.expectEqual(@as(usize, 1), layout.bitmap_count); } +test "BitmapAllocator layout chunks region matches bitmap addressable bytes" { + const testing = std.testing; + + // The chunks region must be exactly the bytes addressable by the + // bitmaps: one chunk per bit of every bitmap. Prior to this being + // fixed, the region was over-reserved by a factor of chunk_size + // (~186 KiB of dead space per standard page), while capacities + // smaller than one full bitmap were under-reserved, allowing + // out-of-bounds allocations. + inline for (.{ 1, 2, 4, 16, 32 }) |chunk| { + const Alloc = BitmapAllocator(chunk); + for ([_]usize{ + 1, + chunk, + chunk + 1, + 48, + 64, + 512, + 1024, + 2048, + 8192, + 8193, + }) |cap| { + const layout = Alloc.layout(cap); + const chunks_size = layout.total_size - layout.chunks_start; + + // Reserved == addressable by the bitmaps. This must match + // capacityBytes() which is computed from the bitmap count. + try testing.expectEqual( + layout.bitmap_count * Alloc.bitmap_bit_size * chunk, + chunks_size, + ); + + // We always reserve at least the requested capacity. + try testing.expect(chunks_size >= cap); + } + } +} + +test "BitmapAllocator layout small capacity cannot alloc out of bounds" { + // Regression test: for capacities smaller than one full bitmap of + // chunks, init marks all bitmap bits as free, so alloc will hand out + // chunks up to the full bitmap. The layout must reserve that entire + // addressable region or those allocations would be out of bounds of + // the backing buffer. + const Alloc = BitmapAllocator(16); + const cap = 48; // 3 chunks, bitmap addresses 64 + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(.init(buf), layout); + + // Allocate every chunk the bitmap can hand out and verify each is + // fully within the backing buffer. Writing to each allocation lets + // the testing allocator catch any out-of-bounds corruption. + const buf_start = @intFromPtr(buf.ptr); + const buf_end = buf_start + buf.len; + var count: usize = 0; + while (bm.alloc(u8, buf, 16)) |slice| { + try testing.expect(@intFromPtr(slice.ptr) >= buf_start); + try testing.expect(@intFromPtr(slice.ptr) + slice.len <= buf_end); + @memset(slice, 0xAA); + count += 1; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + try testing.expectEqual(Alloc.bitmap_bit_size, count); +} + test "BitmapAllocator alloc sequentially" { const Alloc = BitmapAllocator(4); const cap = 64; diff --git a/vendor/libghostty-vt/src/terminal/c/color.zig b/vendor/libghostty-vt/src/terminal/c/color.zig index 3d244a19..ad92883c 100644 --- a/vendor/libghostty-vt/src/terminal/c/color.zig +++ b/vendor/libghostty-vt/src/terminal/c/color.zig @@ -1,8 +1,46 @@ +const std = @import("std"); const lib = @import("../lib.zig"); const color = @import("../color.zig"); +const x11_color = @import("../x11_color.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyColorPaletteMask +pub const PaletteMask = extern struct { + bits: [4]u64, + + /// Convert to the Zig PaletteMask (std.StaticBitSet(256)). + pub fn toZig(self: *const PaletteMask) color.PaletteMask { + var result = color.PaletteMask.initEmpty(); + for (0..256) |i| { + if (((self.bits[i >> 6] >> @as(u6, @intCast(i & 63))) & 1) != 0) { + result.set(i); + } + } + return result; + } +}; + +/// C: GhosttyColorX11Entry +pub const X11Entry = extern struct { + /// Null-terminated color name; null marks the end of the table. + name: ?[*:0]const u8, + color: color.RGB.C, +}; + +/// Comptime-built static table, terminated by a null-name entry. +const x11_entries: [x11_color.entries.len + 1]X11Entry = entries: { + @setEvalBranchQuota(10_000); + var result: [x11_color.entries.len + 1]X11Entry = undefined; + for (x11_color.entries, 0..) |entry, i| result[i] = .{ + .name = entry.name.ptr, + .color = entry.color.cval(), + }; + result[x11_color.entries.len] = .{ .name = null, .color = .{ .r = 0, .g = 0, .b = 0 } }; + break :entries result; +}; pub fn rgb_get( - c: color.RGB.C, + c: *const color.RGB.C, r: *u8, g: *u8, b: *u8, @@ -11,3 +49,538 @@ pub fn rgb_get( g.* = c.g; b.* = c.b; } + +pub fn parse_x11( + name_: ?[*]const u8, + len: usize, + out: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const name = (name_ orelse return .invalid_value)[0..len]; + const trimmed = std.mem.trim(u8, name, " \t"); + const rgb = x11_color.map.get(trimmed) orelse return .invalid_value; + out.* = rgb.cval(); + return .success; +} + +pub fn parse( + value_: ?[*]const u8, + len: usize, + out: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const value = (value_ orelse return .invalid_value)[0..len]; + const rgb = color.RGB.parse(value) catch return .invalid_value; + out.* = rgb.cval(); + return .success; +} + +pub fn palette_default(out: *color.PaletteC) callconv(lib.calling_conv) void { + out.* = color.paletteCval(&color.default); +} + +/// Generate a 256-color palette. The output may alias the base input. +pub fn palette_generate( + base_: ?*const color.PaletteC, + skip_: ?*const PaletteMask, + bg: *const color.RGB.C, + fg: *const color.RGB.C, + harmonious: bool, + out: *color.PaletteC, +) callconv(lib.calling_conv) void { + const base: color.Palette = if (base_) |base| + color.paletteZval(base) + else + color.default; + const skip: color.PaletteMask = if (skip_) |skip| + skip.toZig() + else + .initEmpty(); + const result = color.generate256Color( + base, + skip, + .fromC(bg.*), + .fromC(fg.*), + harmonious, + ); + out.* = color.paletteCval(&result); +} + +pub fn x11_names() callconv(lib.calling_conv) [*]const X11Entry { + return x11_entries[0..].ptr; +} + +pub fn x11_name_count() callconv(lib.calling_conv) usize { + return x11_color.entries.len; +} + +pub fn parse_palette_entry( + value_: ?[*]const u8, + len: usize, + out_index: *u8, + out_rgb: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const value = (value_ orelse return .invalid_value)[0..len]; + const entry = color.parsePaletteEntry(value) catch return .invalid_value; + out_index.* = entry.index; + out_rgb.* = entry.color.cval(); + return .success; +} + +pub fn luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(c.*).luminance(); +} + +pub fn perceived_luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(c.*).perceivedLuminance(); +} + +pub fn contrast(a: *const color.RGB.C, b: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(a.*).contrast(.fromC(b.*)); +} + +fn expectRgb(expected: color.RGB, actual: color.RGB.C) !void { + try std.testing.expectEqual(expected, color.RGB.fromC(actual)); +} + +fn expectPaletteC(expected: *const color.PaletteC, actual: *const color.PaletteC) !void { + for (expected.*, actual.*) |expected_rgb, actual_rgb| { + try expectRgb(color.RGB.fromC(expected_rgb), actual_rgb); + } +} + +fn generatePalette( + base_: ?*const color.PaletteC, + skip_: ?*const PaletteMask, + bg: color.RGB, + fg: color.RGB, + harmonious: bool, +) color.PaletteC { + var out: color.PaletteC = undefined; + const bg_c = bg.cval(); + const fg_c = fg.cval(); + palette_generate(base_, skip_, &bg_c, &fg_c, harmonious, &out); + return out; +} + +fn setMaskBit(mask: *PaletteMask, idx: usize) void { + mask.bits[idx >> 6] |= @as(u64, 1) << @as(u6, @intCast(idx & 63)); +} + +test "color: parse_x11 valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + expected: color.RGB, + }{ + .{ .input = "white", .expected = .{ .r = 255, .g = 255, .b = 255 } }, + .{ .input = "medium spring green", .expected = .{ .r = 0, .g = 250, .b = 154 } }, + .{ .input = "ForestGreen", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = "FoReStGReen", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = " Forest Green ", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = "\tblack\t", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.success, parse_x11(case.input.ptr, case.input.len, &out)); + try expectRgb(case.expected, out); + } +} + +test "color: parse_x11 invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "nosuchcolor", + "", + " ", + "#ffffff", + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_x11(case.ptr, case.len, &out)); + } + + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_x11(null, 0, &out)); +} + +test "color: parse valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + expected: color.RGB, + }{ + .{ .input = "black", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + .{ .input = "#AABBCC", .expected = .{ .r = 170, .g = 187, .b = 204 } }, + .{ .input = "0A0B0C", .expected = .{ .r = 10, .g = 11, .b = 12 } }, + .{ .input = "FFF", .expected = .{ .r = 255, .g = 255, .b = 255 } }, + .{ .input = "#345", .expected = .{ .r = 51, .g = 68, .b = 85 } }, + .{ .input = "rgb:1/2/3", .expected = .{ .r = 17, .g = 34, .b = 51 } }, + .{ .input = " black ", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + .{ .input = " #AABBCC ", .expected = .{ .r = 170, .g = 187, .b = 204 } }, + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.success, parse(case.input.ptr, case.input.len, &out)); + try expectRgb(case.expected, out); + } +} + +test "color: parse invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "", + "notacolor", + "#12345", + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse(case.ptr, case.len, &out)); + } + + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse(null, 0, &out)); +} + +test "color: parse_palette_entry valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + index: u8, + expected: color.RGB, + }{ + .{ .input = "0=#AABBCC", .index = 0, .expected = .{ .r = 170, .g = 187, .b = 204 } }, + .{ .input = "0xF=#ABCDEF", .index = 15, .expected = .{ .r = 171, .g = 205, .b = 239 } }, + .{ .input = "0b1=#014589", .index = 1, .expected = .{ .r = 1, .g = 69, .b = 137 } }, + .{ .input = "0o7=#234567", .index = 7, .expected = .{ .r = 35, .g = 69, .b = 103 } }, + .{ .input = " 1= #DDEEFF ", .index = 1, .expected = .{ .r = 221, .g = 238, .b = 255 } }, + .{ .input = "1=black", .index = 1, .expected = .{ .r = 0, .g = 0, .b = 0 } }, + }; + + for (cases) |case| { + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.success, parse_palette_entry( + case.input.ptr, + case.input.len, + &index, + &rgb, + )); + try testing.expectEqual(case.index, index); + try expectRgb(case.expected, rgb); + } +} + +test "color: parse_palette_entry invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "256=#AABBCC", + "a", + "", + "1=notacolor", + }; + + for (cases) |case| { + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_palette_entry( + case.ptr, + case.len, + &index, + &rgb, + )); + } + + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_palette_entry(null, 0, &index, &rgb)); +} + +test "color: palette_default" { + var out: color.PaletteC = undefined; + palette_default(&out); + + const expected = color.paletteCval(&color.default); + try expectPaletteC(&expected, &out); + + try expectRgb(.{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, out[0]); + try expectRgb(.{ .r = 0, .g = 0, .b = 0 }, out[16]); + try expectRgb(.{ .r = 255, .g = 255, .b = 255 }, out[231]); + try expectRgb(.{ .r = 8, .g = 8, .b = 8 }, out[232]); + try expectRgb(.{ .r = 238, .g = 238, .b = 238 }, out[255]); +} + +test "color: palette_generate base16 preserved" { + const testing = std.testing; + + const base = color.paletteCval(&color.default); + const palette = generatePalette( + &base, + null, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + for (0..16) |i| { + try testing.expectEqual(color.RGB.fromC(base[i]), color.RGB.fromC(palette[i])); + } +} + +test "color: palette_generate cube corners" { + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const palette = generatePalette(null, null, bg, fg, false); + + try expectRgb(bg, palette[16]); + try expectRgb(fg, palette[231]); +} + +test "color: palette_generate light theme harmonious=false" { + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + + const normal = generatePalette(null, null, white, black, false); + try expectRgb(black, normal[16]); + try expectRgb(white, normal[231]); + + const harmonious = generatePalette(null, null, white, black, true); + try expectRgb(white, harmonious[16]); + try expectRgb(black, harmonious[231]); +} + +test "color: palette_generate grayscale monotonic" { + const testing = std.testing; + + const palette = generatePalette( + null, + null, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + var prev_lum: f64 = 0.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum >= prev_lum); + prev_lum = lum; + } +} + +test "color: palette_generate skip mask" { + const testing = std.testing; + + const base = color.paletteCval(&color.default); + var skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + setMaskBit(&skip, 20); + setMaskBit(&skip, 100); + setMaskBit(&skip, 240); + + const palette = generatePalette( + &base, + &skip, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + try testing.expectEqual(color.RGB.fromC(base[20]), color.RGB.fromC(palette[20])); + try testing.expectEqual(color.RGB.fromC(base[100]), color.RGB.fromC(palette[100])); + try testing.expectEqual(color.RGB.fromC(base[240]), color.RGB.fromC(palette[240])); + try testing.expect(!color.RGB.fromC(palette[21]).eql(color.RGB.fromC(base[21]))); +} + +test "color: palette_generate dark harmonious no-op" { + const testing = std.testing; + + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const normal = generatePalette(null, null, bg, fg, false); + const harmonious = generatePalette(null, null, bg, fg, true); + + for (16..256) |i| { + try testing.expectEqual(color.RGB.fromC(normal[i]), color.RGB.fromC(harmonious[i])); + } +} + +test "color: palette_generate light harmonious ramp" { + const testing = std.testing; + + const bg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const fg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + + { + const palette = generatePalette(null, null, bg, fg, false); + var prev_lum: f64 = 0.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum >= prev_lum); + prev_lum = lum; + } + } + + { + const palette = generatePalette(null, null, bg, fg, true); + var prev_lum: f64 = 1.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum <= prev_lum); + prev_lum = lum; + } + } +} + +test "color: palette_generate NULL base uses default" { + const base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 5, .g = 10, .b = 15 }; + const fg = color.RGB{ .r = 245, .g = 250, .b = 255 }; + + const null_base = generatePalette(null, null, bg, fg, false); + const explicit = generatePalette(&base, null, bg, fg, false); + try expectPaletteC(&explicit, &null_base); +} + +test "color: palette_generate NULL skip" { + const base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 5, .g = 10, .b = 15 }; + const fg = color.RGB{ .r = 245, .g = 250, .b = 255 }; + const skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + + const null_skip = generatePalette(&base, null, bg, fg, false); + const explicit = generatePalette(&base, &skip, bg, fg, false); + try expectPaletteC(&explicit, &null_skip); +} + +test "color: palette_generate matches generate256Color" { + const base_c = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 16, .g = 32, .b = 48 }; + const fg = color.RGB{ .r = 240, .g = 224, .b = 208 }; + var skip_c: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + setMaskBit(&skip_c, 20); + setMaskBit(&skip_c, 100); + setMaskBit(&skip_c, 240); + + const actual = generatePalette(&base_c, &skip_c, bg, fg, true); + const expected_z = color.generate256Color( + color.paletteZval(&base_c), + skip_c.toZig(), + bg, + fg, + true, + ); + const expected = color.paletteCval(&expected_z); + try expectPaletteC(&expected, &actual); +} + +test "color: palette_generate in-place" { + var base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const expected = generatePalette(&base, null, bg, fg, false); + + const bg_c = bg.cval(); + const fg_c = fg.cval(); + palette_generate(&base, null, &bg_c, &fg_c, false, &base); + try expectPaletteC(&expected, &base); +} + +test "color: luminance" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const black_c = black.cval(); + const white_c = white.cval(); + try testing.expectApproxEqAbs(@as(f64, 0.0), luminance(&black_c), 0.000001); + try testing.expectApproxEqAbs(@as(f64, 1.0), luminance(&white_c), 0.000001); + + const samples = [_]color.RGB{ + .{ .r = 40, .g = 44, .b = 52 }, + .{ .r = 171, .g = 205, .b = 239 }, + .{ .r = 12, .g = 34, .b = 56 }, + }; + for (samples) |sample| { + const sample_c = sample.cval(); + try testing.expectApproxEqAbs(sample.luminance(), luminance(&sample_c), 0.000001); + } +} + +test "color: perceived_luminance" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const dark = color.RGB{ .r = 0x28, .g = 0x2C, .b = 0x34 }; + const black_c = black.cval(); + const white_c = white.cval(); + const dark_c = dark.cval(); + try testing.expectApproxEqAbs(@as(f64, 0.0), perceived_luminance(&black_c), 0.000001); + try testing.expectApproxEqAbs(@as(f64, 1.0), perceived_luminance(&white_c), 0.000001); + try testing.expect(perceived_luminance(&dark_c) < 0.5); + try testing.expect(perceived_luminance(&white_c) > 0.5); + + const samples = [_]color.RGB{ + dark, + .{ .r = 171, .g = 205, .b = 239 }, + .{ .r = 12, .g = 34, .b = 56 }, + }; + for (samples) |sample| { + const sample_c = sample.cval(); + try testing.expectApproxEqAbs( + sample.perceivedLuminance(), + perceived_luminance(&sample_c), + 0.000001, + ); + } +} + +test "color: contrast" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const gray = color.RGB{ .r = 128, .g = 128, .b = 128 }; + const black_c = black.cval(); + const white_c = white.cval(); + const gray_c = gray.cval(); + + try testing.expectApproxEqAbs(@as(f64, 21.0), contrast(&white_c, &black_c), 0.000001); + try testing.expectApproxEqAbs( + contrast(&black_c, &white_c), + contrast(&white_c, &black_c), + 0.000001, + ); + try testing.expectApproxEqAbs(@as(f64, 1.0), contrast(&gray_c, &gray_c), 0.000001); +} + +test "color: x11_names" { + const testing = std.testing; + + const names = x11_names(); + const count = x11_name_count(); + try testing.expectEqual(x11_color.entries.len, count); + try testing.expect(count > 700); + + var walked: usize = 0; + while (names[walked].name) |name| : (walked += 1) { + const name_slice = std.mem.span(name); + const expected = x11_color.map.get(name_slice).?; + try testing.expectEqual(expected, color.RGB.fromC(names[walked].color)); + } + + try testing.expectEqual(count, walked); + try testing.expectEqual(@as(?[*:0]const u8, null), names[count].name); +} + +test "color: x11_names static" { + try std.testing.expectEqual(@intFromPtr(x11_names()), @intFromPtr(x11_names())); +} diff --git a/vendor/libghostty-vt/src/terminal/c/color_scheme.zig b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig new file mode 100644 index 00000000..ee022b35 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const device_status = @import("../device_status.zig"); +const Result = @import("result.zig").Result; + +pub fn report_encode( + scheme: device_status.ColorScheme, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + device_status.encodeColorSchemeReport(&writer, scheme) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + device_status.encodeColorSchemeReport(&discarding.writer, scheme) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode color scheme report dark" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;1n", buf[0..written]); +} + +test "encode color scheme report light" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;2n", buf[0..written]); +} + +test "encode color scheme report with null buffer" { + var written: usize = 0; + const result = report_encode(.dark, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with insufficient buffer" { + var buf: [3]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with exact buffer" { + var buf: [9]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqual(@as(usize, 9), written); +} diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig index db7ff2e8..946ca8a1 100644 --- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig +++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig @@ -98,7 +98,8 @@ pub fn grid_ref_hyperlink_uri( out_len: *usize, ) callconv(lib.calling_conv) Result { const p = ref.toPin() orelse return .invalid_value; - const rac = p.node.data.getRowAndCell(p.x, p.y); + const terminal_page = p.node.page(); + const rac = terminal_page.getRowAndCell(p.x, p.y); const cell = rac.cell; if (!cell.hyperlink) { @@ -106,12 +107,15 @@ pub fn grid_ref_hyperlink_uri( return .success; } - const link_id = p.node.data.lookupHyperlink(cell) orelse { + const link_id = terminal_page.lookupHyperlink(cell) orelse { out_len.* = 0; return .success; }; - const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id); - const uri = entry.uri.slice(p.node.data.memory); + const entry = terminal_page.hyperlink_set.get( + terminal_page.memory, + link_id, + ); + const uri = entry.uri.slice(terminal_page.memory); if (out_buf == null or buf_len < uri.len) { out_len.* = uri.len; @@ -133,8 +137,9 @@ pub fn grid_ref_style( if (cell.style_id == stylepkg.default_id) { o.* = .fromStyle(.{}); } else { - o.* = .fromStyle(p.node.data.styles.get( - p.node.data.memory, + const terminal_page = p.node.page(); + o.* = .fromStyle(terminal_page.styles.get( + terminal_page.memory, cell.style_id, ).*); } diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig index a00632ed..13a675cc 100644 --- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -52,11 +52,13 @@ else pub const Data = enum(c_int) { invalid = 0, placement_iterator = 1, + generation = 2, pub fn OutType(comptime self: Data) type { return switch (self) { .invalid => void, .placement_iterator => PlacementIterator, + .generation => u64, }; } }; @@ -129,6 +131,7 @@ fn getTyped( .layer_filter = it.layer_filter, }; }, + .generation => out.* = storage.generation, } return .success; } @@ -178,7 +181,7 @@ pub const ImageData = enum(c_int) { compression = 6, data_ptr = 7, data_len = 8, - transmit_time_ns = 9, + generation = 9, pub fn OutType(comptime self: ImageData) type { return switch (self) { @@ -188,7 +191,7 @@ pub const ImageData = enum(c_int) { .compression => ImageCompression, .data_ptr => [*]const u8, .data_len => usize, - .transmit_time_ns => u64, + .generation => u64, }; } }; @@ -260,22 +263,12 @@ fn imageGetTyped( .compression => out.* = image.compression, .data_ptr => out.* = image.data.ptr, .data_len => out.* = image.data.len, - .transmit_time_ns => out.* = instantNanos(image.transmit_time), + .generation => out.* = image.generation, } return .success; } -/// Flattens an Instant's platform timestamp to nanoseconds for the C API. -/// The epoch is unspecified; only equality and ordering are meaningful. -fn instantNanos(instant: std.time.Instant) u64 { - if (@TypeOf(instant.timestamp) == u64) return instant.timestamp; - const ts = instant.timestamp; - const sec: u64 = @intCast(@max(ts.sec, 0)); - const nsec: u64 = @intCast(@max(ts.nsec, 0)); - return sec *| std.time.ns_per_s +| nsec; -} - pub fn placement_iterator_new( alloc_: ?*const CAllocator, out: *PlacementIterator, @@ -990,48 +983,6 @@ test "image_get_handle and image_get with transmitted image" { try testing.expect(data_len > 0); } -test "image_get transmit_time_ns changes on retransmission" { - if (comptime !build_options.kitty_graphics) return error.SkipZigTest; - - var t: terminal_c.Terminal = null; - try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, - &t, - .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, - )); - defer terminal_c.free(t); - - const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; - terminal_c.vt_write(t, cmd.ptr, cmd.len); - - var graphics: KittyGraphics = undefined; - try testing.expectEqual(Result.success, terminal_c.get( - t, - .kitty_graphics, - @ptrCast(&graphics), - )); - - var first: u64 = undefined; - try testing.expectEqual(Result.success, image_get( - image_get_handle(graphics, 1), - .transmit_time_ns, - @ptrCast(&first), - )); - try testing.expect(first > 0); - - std.Thread.sleep(1 * std.time.ns_per_ms); - const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; - terminal_c.vt_write(t, retransmit.ptr, retransmit.len); - - var second: u64 = undefined; - try testing.expectEqual(Result.success, image_get( - image_get_handle(graphics, 1), - .transmit_time_ns, - @ptrCast(&second), - )); - try testing.expect(second > first); -} - test "placement_rect with transmit and display" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -1763,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" { var values = [_]?*anyopaque{@ptrCast(&id)}; try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null)); } + +test "storage generation via get" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + // Fresh storage: generation zero. + var gen0: u64 = 99; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0))); + try testing.expectEqual(0, gen0); + + // Transmit bumps the generation. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen1: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1))); + try testing.expect(gen1 > gen0); + + // Unrelated terminal writes (plain text) do not bump it. + const text = "hello world"; + terminal_c.vt_write(t, text.ptr, text.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen2: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2))); + try testing.expectEqual(gen1, gen2); + + // Placement bumps it. + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, display.ptr, display.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen3: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3))); + try testing.expect(gen3 > gen2); + + // Delete bumps it. + const del = "\x1b_Ga=d,d=A\x1b\\"; + terminal_c.vt_write(t, del.ptr, del.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen4: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4))); + try testing.expect(gen4 > gen3); +} + +test "image generation detects same-sized retransmission" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a 1x2 RGB image with id=1. + const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit1.ptr, transmit1.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + var gen1: u64 = 0; + var w1: u32 = 0; + var h1: u32 = 0; + var len1: usize = 0; + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1))); + try testing.expect(gen1 > 0); + } + + // Retransmit the same ID with identical dimensions but different + // pixel bytes. All size heuristics match; only the generation + // reveals the change. + const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; + terminal_c.vt_write(t, transmit2.ptr, transmit2.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + var gen2: u64 = 0; + var w2: u32 = 0; + var h2: u32 = 0; + var len2: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2))); + + // Size heuristics are identical... + try testing.expectEqual(w1, w2); + try testing.expectEqual(h1, h2); + try testing.expectEqual(len1, len2); + + // ...but the generation changed. + try testing.expect(gen2 > gen1); + } +} + +test "image generation via image_get_multi" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = 0; + var generation: u64 = 0; + var written: usize = 0; + const keys = [_]ImageData{ .id, .generation }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) }; + try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(1, id); + try testing.expect(generation > 0); + + // The image stamp came from the same sequence as (and here, the + // same event as) the storage stamp. + var storage_gen: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen))); + try testing.expectEqual(storage_gen, generation); +} + +test "image compression and format always report decoded data" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a zlib-compressed 1x1 RGB image (o=z). The payload is + // base64(zlib([0xFF, 0x00, 0x00])). + const compressed = comptime blk: { + // zlib stream for the 3 bytes FF 00 00 (stored block). + // 78 01: zlib header; 01: final stored block; 03 00 len; + // FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian). + const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 }; + var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined; + const enc = std.base64.standard.Encoder.encode(&buf, &raw); + break :blk enc[0..enc.len].*; + }; + const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + // Compression must report NONE: the data was inflated at + // transmission time. + var comp: ImageCompression = undefined; + try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); + try testing.expectEqual(.none, comp); + + // Data is the decoded pixels: width * height * bpp. + var data_len: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expectEqual(3, data_len); + + var data_ptr: [*]const u8 = undefined; + try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr))); + try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]); +} + +test "generation never recurs across resets and screen switches" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + var gen: u64 = 0; + + // Transmit on the main screen. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_main = gen; + try testing.expect(gen_main > 0); + + // Switch to the alternate screen: its storage is untouched, so its + // generation is zero (which always means "empty"). + const alt_on = "\x1b[?1049h"; + terminal_c.vt_write(t, alt_on.ptr, alt_on.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + // A transmit on the alt screen draws from the same global sequence, + // so its stamp is strictly greater than anything seen on the main + // screen: an embedder keying a cache on the generation value alone + // can never confuse the two storages. + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_alt = gen; + try testing.expect(gen_alt > gen_main); + + // Back to the main screen: its generation is unchanged. + const alt_off = "\x1b[?1049l"; + terminal_c.vt_write(t, alt_off.ptr, alt_off.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(gen_main, gen); + + // Reset zeroes the storage (empty), and the next mutation continues + // from the global sequence: past values are never reused for + // different content. + terminal_c.reset(t); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expect(gen > gen_alt); +} diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 648bdbe5..37dc5768 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -5,6 +5,7 @@ const buildpkg = @import("build_info.zig"); pub const allocator = @import("allocator.zig"); pub const cell = @import("cell.zig"); pub const color = @import("color.zig"); +pub const color_scheme = @import("color_scheme.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); @@ -43,6 +44,7 @@ pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); +pub const unicode = @import("unicode.zig"); // The full C API, unexported. pub const build_info = buildpkg.get; @@ -56,6 +58,18 @@ pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const color_contrast = color.contrast; +pub const color_luminance = color.luminance; +pub const color_parse = color.parse; +pub const color_parse_palette_entry = color.parse_palette_entry; +pub const color_parse_x11 = color.parse_x11; +pub const color_palette_default = color.palette_default; +pub const color_palette_generate = color.palette_generate; +pub const color_perceived_luminance = color.perceived_luminance; +pub const color_x11_name_count = color.x11_name_count; +pub const color_x11_names = color.x11_names; + +pub const color_scheme_report_encode = color_scheme.report_encode; pub const focus_encode = focus.encode; @@ -69,6 +83,8 @@ pub const formatter_free = formatter.free; pub const render_state_new = render.new; pub const render_state_free = render.free; pub const render_state_update = render.update; +pub const render_state_begin_update = render.begin_update; +pub const render_state_end_update = render.end_update; pub const render_state_get = render.get; pub const render_state_get_multi = render.get_multi; pub const render_state_set = render.set; @@ -167,6 +183,8 @@ pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; +pub const terminal_compression_activity = terminal.compression_activity; +pub const terminal_compress = terminal.compress; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; @@ -198,6 +216,9 @@ pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; +pub const unicode_codepoint_width = unicode.codepoint_width; +pub const unicode_grapheme_width = unicode.grapheme_width; + pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; @@ -214,6 +235,7 @@ test { _ = buildpkg; _ = cell; _ = color; + _ = color_scheme; _ = grid_ref; _ = grid_ref_tracked; _ = kitty_graphics; @@ -236,6 +258,7 @@ test { _ = sys; _ = terminal; _ = types; + _ = unicode; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig index 0f3b2e78..6cf78ec5 100644 --- a/vendor/libghostty-vt/src/terminal/c/render.zig +++ b/vendor/libghostty-vt/src/terminal/c/render.zig @@ -188,6 +188,25 @@ pub fn update( return .success; } +pub fn begin_update( + state_: RenderState, + terminal_: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + + state.state.beginUpdate(state.alloc, t) catch return .out_of_memory; + return .success; +} + +pub fn end_update( + state_: RenderState, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + state.state.endUpdate(); + return .success; +} + pub fn get( state_: RenderState, data: Data, @@ -591,7 +610,7 @@ fn rowCellsGetGraphemesUtf8( if (!cell.hasText()) return .success; - var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch return .invalid_value; for (extra) |cp| { needed += std.unicode.utf8CodepointSequenceLength(cp) catch @@ -787,6 +806,61 @@ test "render: update invalid value" { try testing.expectEqual(Result.invalid_value, update(state, null)); } +test "render: begin/end update invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, begin_update(null, null)); + try testing.expectEqual(Result.invalid_value, begin_update(state, null)); + try testing.expectEqual(Result.invalid_value, end_update(null)); + + // End without a begin is safe. + try testing.expectEqual(Result.success, end_update(state)); +} + +test "render: begin/end update" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 10, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write some styled text so that the update has deferred work. + const t = terminal.?.terminal; + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + // Begin should record pending work, end should complete it. + try testing.expectEqual(Result.success, begin_update(state, terminal)); + try testing.expect(state.?.state.pending_styles.items.len > 0); + try testing.expectEqual(Result.success, end_update(state)); + try testing.expectEqual(0, state.?.state.pending_styles.items.len); + + // The cell styles should be complete. + const row_data = state.?.state.row_data.slice(); + const cells = row_data.items(.cells); + try testing.expect(cells[0].get(0).style.flags.bold); + try testing.expect(cells[0].get(1).style.flags.bold); +} + test "render: get invalid value" { var cols: size.CellCountInt = 0; try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); @@ -1293,6 +1367,82 @@ test "render: row cells get has_styling" { } test "render: row cells get graphemes utf8" { + const cases = [_]struct { + terminal_input: []const u8, + expected: []const u8, + }{ + .{ + .terminal_input = "e\u{301}", + .expected = "e\u{301}", + }, + .{ + .terminal_input = "\x1b[?2027h\u{1F1FA}\u{1F1F8}", + .expected = "\u{1F1FA}\u{1F1F8}", + }, + .{ + .terminal_input = "\x1b[?2027h\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", + .expected = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", + }, + }; + + for (cases) |case| { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, case.terminal_input.ptr, case.terminal_input.len); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + + var text: lib.Buffer = .{}; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + + var small = [_]u8{'x'} ** 32; + const small_cap = case.expected.len - 1; + text = .{ .ptr = small[0..small_cap].ptr, .cap = small_cap }; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + try testing.expectEqualSlices(u8, &([_]u8{'x'} ** 32), &small); + + var buf: [32]u8 = undefined; + text = .{ .ptr = &buf, .cap = case.expected.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + try testing.expectEqualStrings(case.expected, buf[0..text.len]); + } + var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( &lib.alloc.test_allocator, @@ -1331,26 +1481,9 @@ test "render: row cells get graphemes utf8" { try testing.expect(row_iterator_next(it)); try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); - try testing.expectEqual(Result.success, row_cells_select(cells, 0)); - - var text: lib.Buffer = .{}; - try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(@as(usize, input.len), text.len); - - var small = [_]u8{ 'x', 'x' }; - text = .{ .ptr = &small, .cap = small.len }; - try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(@as(usize, input.len), text.len); - try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small); - - var buf: [8]u8 = undefined; - text = .{ .ptr = &buf, .cap = buf.len }; - try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(input.len, text.len); - try testing.expectEqualStrings(input, buf[0..text.len]); - try testing.expectEqual(Result.success, row_cells_select(cells, 1)); - text = .{ .ptr = &buf, .cap = buf.len }; + var buf: [8]u8 = undefined; + var text: lib.Buffer = .{ .ptr = &buf, .cap = buf.len }; try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); try testing.expectEqual(@as(usize, 0), text.len); } diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index a7ad35a6..a896c0fd 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -5,6 +5,7 @@ const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; pub const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; +const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const apc = @import("../apc.zig"); @@ -23,6 +24,7 @@ const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); +const clipboard = @import("../clipboard.zig"); const Result = @import("result.zig").Result; const Handler = @import("../stream_terminal.zig").Handler; @@ -39,6 +41,24 @@ const TerminalWrapper = struct { tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, }; +/// A single MIME representation in a clipboard write. +/// +/// C: GhosttyClipboardContent +pub const ClipboardContent = extern struct { + mime: lib.String, + data: lib.String, +}; + +/// A protocol-neutral request to replace or clear clipboard contents. +/// +/// C: GhosttyClipboardWrite +pub const ClipboardWrite = extern struct { + size: usize, + location: clipboard.Location, + contents: ?[*]const ClipboardContent, + contents_len: usize, +}; + /// C callback state for terminal effects. Trampolines are always /// installed on the stream handler; they check these fields and /// no-op when the corresponding callback is null. @@ -51,7 +71,9 @@ const Effects = struct { enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, + pwd_changed: ?PwdChangedFn = null, size_cb: ?SizeFn = null, + clipboard_write: ?ClipboardWriteFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -82,9 +104,16 @@ const Effects = struct { /// (len=0) causes the default "libghostty" to be reported. pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + /// C function pointer type for the clipboard_write callback. The request + /// and its contents are borrowed and only valid for the callback duration. + pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult; + /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the pwd_changed callback. + pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -133,6 +162,44 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.clipboard_write orelse return .unsupported; + + // Most protocols currently produce one representation, so keep that + // path allocation-free while supporting arbitrary multi-MIME writes. + var stack_contents: [4]ClipboardContent = undefined; + const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len) + stack_contents[0..write.contents.len] + else + wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch + return .io_error; + defer if (write.contents.len > stack_contents.len) + wrapper.terminal.gpa().free(contents); + + for (contents, write.contents) |*c_content, content| { + c_content.* = .{ + .mime = .{ + .ptr = content.mime.ptr, + .len = content.mime.len, + }, + .data = .{ + .ptr = content.data.ptr, + .len = content.data.len, + }, + }; + } + + const request: ClipboardWrite = .{ + .size = @sizeOf(ClipboardWrite), + .location = write.location, + .contents = if (contents.len > 0) contents.ptr else null, + .contents_len = contents.len, + }; + return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -198,6 +265,13 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn pwdChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.pwd_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -211,6 +285,12 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +/// C: GhosttyTerminalCompressionMode +pub const CompressionMode = ZigTerminal.CompressionMode; + +/// C: GhosttyTerminalCompressionResult +pub const CompressionResult = ZigTerminal.CompressionResult; + pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } @@ -283,7 +363,9 @@ fn new_( .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, .title_changed = &Effects.titleChangedTrampoline, + .pwd_changed = &Effects.pwdChangedTrampoline, .size = &Effects.sizeTrampoline, + .clipboard_write = &Effects.clipboardWriteTrampoline, }; wrapper.* = .{ @@ -303,6 +385,30 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub fn compression_activity( + terminal_: Terminal, + out_activity_: ?*u64, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_activity = out_activity_ orelse return .invalid_value; + out_activity.* = t.compressionActivity(); + return .success; +} + +pub fn compress( + terminal_: Terminal, + mode_: c_int, + out_result_: ?*CompressionResult, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_result = out_result_ orelse return .invalid_value; + const mode = std.meta.intToEnum(CompressionMode, mode_) catch + return .invalid_value; + + out_result.* = t.compress(mode); + return .success; +} + /// C: GhosttyTerminalOption pub const Option = enum(c_int) { userdata = 0, @@ -327,6 +433,11 @@ pub const Option = enum(c_int) { apc_max_bytes = 19, apc_max_bytes_kitty = 20, selection = 21, + default_cursor_style = 22, + default_cursor_blink = 23, + glyph_protocol = 24, + pwd_changed = 25, + clipboard_write = 26, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -339,7 +450,9 @@ pub const Option = enum(c_int) { .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, + .pwd_changed => ?Effects.PwdChangedFn, .size_cb => ?Effects.SizeFn, + .clipboard_write => ?Effects.ClipboardWriteFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, @@ -347,9 +460,12 @@ pub const Option = enum(c_int) { .kitty_image_medium_file, .kitty_image_medium_temp_file, .kitty_image_medium_shared_mem, + .glyph_protocol, => ?*const bool, .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize, .selection => ?*const selection_c.CSelection, + .default_cursor_style => ?*const TerminalCursorStyle, + .default_cursor_blink => ?*const bool, }; } }; @@ -391,7 +507,9 @@ fn setTyped( .enquiry => wrapper.effects.enquiry = value, .xtversion => wrapper.effects.xtversion = value, .title_changed => wrapper.effects.title_changed = value, + .pwd_changed => wrapper.effects.pwd_changed = value, .size_cb => wrapper.effects.size_cb = value, + .clipboard_write => wrapper.effects.clipboard_write = value, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setTitle(str) catch return .out_of_memory; @@ -457,6 +575,11 @@ fn setTyped( wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty); } }, + .glyph_protocol => { + const enabled = (value orelse return .success).*; + wrapper.stream.handler.apc_handler.enable(.glyph, enabled); + if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa()); + }, .selection => { if (value) |ptr| { const sel = ptr.toZig() orelse return .invalid_value; @@ -465,10 +588,43 @@ fn setTyped( wrapper.terminal.screens.active.clearSelection(); } }, + .default_cursor_style => { + const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value; + wrapper.stream.handler.default_cursor_style = style; + if (wrapper.stream.handler.default_cursor) { + wrapper.terminal.screens.active.cursor.cursor_style = style; + } + }, + .default_cursor_blink => { + const blink = if (value) |ptr| ptr.* else false; + wrapper.stream.handler.default_cursor_blink = blink; + if (wrapper.stream.handler.default_cursor) { + wrapper.terminal.modes.set(.cursor_blinking, blink); + } + }, } return .success; } +/// C: GhosttyTerminalCursorStyle +pub const TerminalCursorStyle = enum(c_int) { + bar = 0, + block = 1, + underline = 2, + block_hollow = 3, + _, + + fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle { + return switch (self) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + _ => null, + }; + } +}; + /// C: GhosttyDeviceAttributes pub const DeviceAttributes = Effects.CDeviceAttributes; @@ -484,6 +640,7 @@ pub fn scroll_viewport( .top => .top, .bottom => .bottom, .delta => .{ .delta = behavior.value.delta }, + .row => .{ .row = behavior.value.row }, }); } @@ -938,8 +1095,211 @@ test "scroll_viewport" { } } +test "scroll_viewport row" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?.terminal; + + // Write 4 rows so that rows "1" and "2" are pushed into scrollback: + // total rows is 4, viewport length is 2. + vt_write(t, "1\r\n2\r\n3\r\n4", 10); + + var viewport_active: bool = false; + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // Row 0 is the top of the scrollback. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2", str); + } + + // An absolute row within the scrollback becomes the first visible + // row and round-trips through the scrollbar offset. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3", str); + } + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 4), scrollbar_data.total); + try testing.expectEqual(@as(u64, 1), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); + + // A row past the end clamps to the active area. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("3\n4", str); + } + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.offset); +} + +test "scroll_viewport row alt screen" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + // Enter the alternate screen, which has no scrollback. + vt_write(t, "\x1b[?1049h", 8); + var screen: TerminalScreen = undefined; + try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen))); + try testing.expectEqual(TerminalScreen.alternate, screen); + + // Scrolling to any row keeps the viewport on the active area. + var viewport_active: bool = false; + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // With no scrollback the scrollbar covers exactly the active area. + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.total); + try testing.expectEqual(@as(u64, 0), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); +} + test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); + scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } }); +} + +test "compression invalid arguments" { + var activity: u64 = undefined; + var compression_result: CompressionResult = undefined; + + try testing.expectEqual( + Result.invalid_value, + compression_activity(null, &activity), + ); + try testing.expectEqual( + Result.invalid_value, + compress(null, @intFromEnum(CompressionMode.incremental), &compression_result), + ); + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + try testing.expectEqual( + Result.invalid_value, + compression_activity(t, null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, @intFromEnum(CompressionMode.incremental), null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, -1, &compression_result), + ); +} + +test "compression activity and incremental scheduling" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + var initial_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &initial_activity), + ); + + const line = "repeated and compressible terminal history\r\n"; + const repeat = 4_000; + const input = try testing.allocator.alloc(u8, line.len * repeat); + defer testing.allocator.free(input); + for (0..repeat) |i| + @memcpy(input[i * line.len ..][0..line.len], line); + vt_write(t, input.ptr, input.len); + + var activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &activity), + ); + try testing.expect(activity != initial_activity); + + var compression_result: CompressionResult = undefined; + for (0..1_000) |_| { + try testing.expectEqual( + Result.success, + compress( + t, + @intFromEnum(CompressionMode.incremental), + &compression_result, + ), + ); + + switch (compression_result) { + .pending => continue, + .complete => break, + .unsupported => unreachable, + } + } else return error.TestUnexpectedResult; + + // Compression changes storage representation, not the activity token. + var final_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &final_activity), + ); + try testing.expectEqual(activity, final_activity); + + try testing.expectEqual( + Result.success, + compress(t, @intFromEnum(CompressionMode.full), &compression_result), + ); + try testing.expectEqual(CompressionResult.complete, compression_result); } test "reset" { @@ -1019,11 +1379,12 @@ test "resize shrinks both axes with cursor at bottom" { )); defer free(t); - // CSI 24;1H parks the cursor on the bottom row. + // CSI 24;1H -> park the cursor on the bottom row (1-based). const move = "\x1b[24;1H"; vt_write(t, move, move.len); // Shrink both axes; pre-resize cursor.y sits past the new bottom row. + // Previously this underflowed in PageList.resizeCols. try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16)); try testing.expectEqual(79, t.?.terminal.cols); try testing.expectEqual(23, t.?.terminal.rows); @@ -1401,6 +1762,45 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } +test "set default cursor style and blink" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var default_style: TerminalCursorStyle = .bar; + var default_blink = true; + try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style))); + try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink))); + + // Setting defaults applies them immediately while the cursor is still default. + try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(t.?.terminal.modes.get(.cursor_blinking)); + + // An explicit DECSCUSR style overrides the configured defaults. + vt_write(t, "\x1b[2 q", 5); + try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(!t.?.terminal.modes.get(.cursor_blinking)); + + // Changing defaults does not override an explicit cursor style. + default_style = .underline; + try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style))); + try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(!t.?.terminal.modes.get(.cursor_blinking)); + + // DECSCUSR reset restores the configured default style and blink. + vt_write(t, "\x1b[0 q", 5); + try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(t.?.terminal.modes.get(.cursor_blinking)); +} + test "set and get selection" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1982,6 +2382,46 @@ test "set write_pty callback" { try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); } +test "write_pty receives OSC color query response" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + const set_fg = "\x1B]10;rgb:01/02/03\x1B\\"; + vt_write(t, set_fg, set_fg.len); + try testing.expect(S.last_data == null); + + const query_fg = "\x1B]10;?\x1B\\"; + vt_write(t, query_fg, query_fg.len); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?); +} + test "set write_pty without callback ignores queries" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -2279,6 +2719,248 @@ test "title_changed without callback is silent" { vt_write(t, "\x1B]2;Hello\x1B\\", 10); } +test "set pwd_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var pwd_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { + pwd_count += 1; + last_userdata = ud; + } + }; + S.pwd_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged))); + + // OSC 7 ; file:///tmp ST — report pwd + const seq1 = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq1, seq1.len); + try testing.expectEqual(@as(usize, 1), S.pwd_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); + + // Another pwd change + const seq2 = "\x1B]7;file:///home/user\x1B\\"; + vt_write(t, seq2, seq2.len); + try testing.expectEqual(@as(usize, 2), S.pwd_count); + try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); +} + +test "set clipboard_write callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_terminal: Terminal = null; + var last_userdata: ?*anyopaque = null; + var last_size: usize = 0; + var last_location: clipboard.Location = .standard; + var last_contents_null: bool = false; + var last_contents_len: usize = 0; + var last_mimes: [8][64]u8 = undefined; + var last_mime_lens: [8]usize = @splat(0); + var last_data: [8][64]u8 = undefined; + var last_data_lens: [8]usize = @splat(0); + var next_result: clipboard.WriteResult = .success; + + fn clipboardWrite( + terminal_: Terminal, + ud: ?*anyopaque, + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) clipboard.WriteResult { + count += 1; + last_terminal = terminal_; + last_userdata = ud; + last_size = request.size; + last_location = request.location; + last_contents_null = request.contents == null; + last_contents_len = request.contents_len; + + if (request.contents) |ptr| { + for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| { + last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len); + @memcpy( + last_mimes[i][0..last_mime_lens[i]], + content.mime.ptr[0..last_mime_lens[i]], + ); + + last_data_lens[i] = @min(content.data.len, last_data[i].len); + @memcpy( + last_data[i][0..last_data_lens[i]], + content.data.ptr[0..last_data_lens[i]], + ); + } + } + + return next_result; + } + }; + S.count = 0; + S.last_terminal = null; + S.last_userdata = null; + S.next_result = .denied; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); + + // Split OSC 52 write whose decoded payload contains an embedded NUL. + const seq1_a = "\x1B]52;c;aGVs"; + const seq1_b = "bG8Ad29ybGQ=\x1B\\"; + vt_write(t, seq1_a, seq1_a.len); + vt_write(t, seq1_b, seq1_b.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(t, S.last_terminal); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expect(!S.last_contents_null); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); + try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]); + + // OSC 52 destinations are normalized rather than exposed as wire bytes. + const location_cases = [_]struct { + selector: u8, + expected: clipboard.Location, + }{ + .{ .selector = 's', .expected = .selection }, + .{ .selector = 'p', .expected = .primary }, + .{ .selector = 'q', .expected = .standard }, + }; + for (location_cases) |case| { + const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' }; + vt_write(t, &seq, seq.len); + try testing.expectEqual(case.expected, S.last_location); + } + try testing.expectEqual(@as(usize, 4), S.count); + + // An empty content list is a clear and retains a null descriptor pointer. + const clear = "\x1B]52;s;\x1B\\"; + vt_write(t, clear, clear.len); + try testing.expectEqual(@as(usize, 5), S.count); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expect(S.last_contents_null); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + + // Read requests and malformed base64 must never reach the callback. + const read = "\x1B]52;c;?\x1B\\"; + vt_write(t, read, read.len); + const malformed = "\x1B]52;c;%%%\x1B\\"; + vt_write(t, malformed, malformed.len); + try testing.expectEqual(@as(usize, 5), S.count); + + // iTerm2 Copy reaches the same normalized callback. + const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\"; + vt_write(t, iterm, iterm.len); + try testing.expectEqual(@as(usize, 6), S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); + try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]); + + // Every representation is converted, and callback results propagate + // through the C trampoline for protocols that can acknowledge writes. + const internal_contents = [_]clipboard.Content{ + .{ .mime = "text/plain", .data = "plain" }, + .{ .mime = "application/octet-stream", .data = "a\x00b" }, + .{ .mime = "text/html", .data = "<b>plain</b>" }, + .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" }, + .{ .mime = "image/png", .data = "\x89PNG" }, + }; + S.next_result = .busy; + const handler = &t.?.stream.handler; + const write_result = handler.effects.clipboard_write.?(handler, .{ + .location = .primary, + .contents = &internal_contents, + }); + try testing.expectEqual(clipboard.WriteResult.busy, write_result); + try testing.expectEqual(@as(usize, 7), S.count); + try testing.expectEqual(@as(usize, 5), S.last_contents_len); + try testing.expectEqualStrings( + "application/octet-stream", + S.last_mimes[1][0..S.last_mime_lens[1]], + ); + try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]); + try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]); + try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]); + + // Removing the callback takes effect immediately. + try testing.expectEqual(Result.success, set(t, .clipboard_write, null)); + const after_remove = "\x1B]52;c;eA==\x1B\\"; + vt_write(t, after_remove, after_remove.len); + try testing.expectEqual(@as(usize, 7), S.count); +} + +test "clipboard_write without callback is unsupported and silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 52 without a callback should not crash + const seq = "\x1B]52;c;aGVsbG8=\x1B\\"; + vt_write(t, seq, seq.len); + + const handler = &t.?.stream.handler; + const result = handler.effects.clipboard_write.?(handler, .{ + .location = .standard, + .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + }); + try testing.expectEqual(clipboard.WriteResult.unsupported, result); +} + +test "pwd_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 7 without a callback should not crash, but should still set the pwd + const seq = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq, seq.len); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); +} + test "set size callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -3106,6 +3788,33 @@ test "set color sets dirty flag" { try testing.expect(zt.flags.dirty.palette); } +test "set glyph protocol disables APC handling and clears glossary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + const register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"; + vt_write(t, register, register.len); + try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0)); + + const disabled = false; + try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled))); + try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph)); + try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0)); + + vt_write(t, register, register.len); + try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0)); + + const enabled = true; + try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled))); + vt_write(t, register, register.len); + try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0)); +} + test "get_multi success" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig index de043a03..f160f385 100644 --- a/vendor/libghostty-vt/src/terminal/c/types.zig +++ b/vendor/libghostty-vt/src/terminal/c/types.zig @@ -7,6 +7,7 @@ const std = @import("std"); const lib = @import("../lib.zig"); const color = @import("../color.zig"); +const color_c = @import("color.zig"); const mouse_event = @import("mouse_event.zig"); const point = @import("../point.zig"); const size_report = @import("size_report.zig"); @@ -37,8 +38,12 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, + .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) }, + .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) }, .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, + .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, + .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, @@ -209,9 +214,23 @@ test "json parses" { const root = parsed.value.object; // Verify we have all expected structs + try std.testing.expect(root.contains("GhosttyClipboardContent")); + try std.testing.expect(root.contains("GhosttyClipboardWrite")); try std.testing.expect(root.contains("GhosttyTerminalOptions")); try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); + const clipboard_content = root.get("GhosttyClipboardContent").?.object; + const clipboard_content_fields = clipboard_content.get("fields").?.object; + try std.testing.expect(clipboard_content_fields.contains("mime")); + try std.testing.expect(clipboard_content_fields.contains("data")); + + const clipboard_write = root.get("GhosttyClipboardWrite").?.object; + const clipboard_write_fields = clipboard_write.get("fields").?.object; + try std.testing.expect(clipboard_write_fields.contains("size")); + try std.testing.expect(clipboard_write_fields.contains("location")); + try std.testing.expect(clipboard_write_fields.contains("contents")); + try std.testing.expect(clipboard_write_fields.contains("contents_len")); + // Verify GhosttyTerminalOptions fields const term_opts = root.get("GhosttyTerminalOptions").?.object; try std.testing.expect(term_opts.contains("size")); diff --git a/vendor/libghostty-vt/src/terminal/c/unicode.zig b/vendor/libghostty-vt/src/terminal/c/unicode.zig new file mode 100644 index 00000000..14451338 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/unicode.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const unicode_pkg = @import("../../unicode/main.zig"); + +pub fn codepoint_width(cp: u32) callconv(lib.calling_conv) u8 { + if (cp > 0x10FFFF) return 1; + return unicode_pkg.codepointWidth(@intCast(cp)); +} + +pub fn grapheme_width( + cps: ?[*]const u32, + len: usize, + width: ?*u8, +) callconv(lib.calling_conv) usize { + if (len == 0) { + if (width) |ptr| ptr.* = 0; + return 0; + } + + const ptr = cps orelse { + if (width) |out| out.* = 0; + return 0; + }; + const result = unicode_pkg.graphemeWidth(u32, ptr[0..len]); + if (width) |out| out.* = result.width; + return result.len; +} + +test "codepoint_width narrow" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width('a')); +} + +test "codepoint_width wide" { + const testing = std.testing; + try testing.expectEqual(2, codepoint_width(0x4E00)); +} + +test "codepoint_width zero" { + const testing = std.testing; + try testing.expectEqual(0, codepoint_width(0x0301)); +} + +test "codepoint_width out of range" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width(0x110000)); + try testing.expectEqual(1, codepoint_width(std.math.maxInt(u32))); +} + +test "grapheme_width empty" { + const testing = std.testing; + + var width: u8 = 42; + try testing.expectEqual(@as(usize, 0), grapheme_width(null, 0, &width)); + try testing.expectEqual(@as(u8, 0), width); +} + +test "grapheme_width null width" { + const testing = std.testing; + + const cps = [_]u32{ 0x2764, 0xFE0F }; + try testing.expectEqual(@as(usize, 2), grapheme_width(cps[0..].ptr, cps.len, null)); +} + +test "grapheme_width out of range" { + const testing = std.testing; + + var width: u8 = 0; + const invalid_first = [_]u32{ 0x110000, 0x0301 }; + try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_first[0..].ptr, invalid_first.len, &width)); + try testing.expectEqual(@as(u8, 1), width); + + const invalid_second = [_]u32{ 'a', 0x110000 }; + try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_second[0..].ptr, invalid_second.len, &width)); + try testing.expectEqual(@as(u8, 1), width); +} + +test "grapheme_width emoji sequence" { + const testing = std.testing; + + var width: u8 = 0; + const cps = [_]u32{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 }; + try testing.expectEqual(@as(usize, cps.len), grapheme_width(cps[0..].ptr, cps.len, &width)); + try testing.expectEqual(@as(u8, 2), width); +} diff --git a/vendor/libghostty-vt/src/terminal/clipboard.zig b/vendor/libghostty-vt/src/terminal/clipboard.zig new file mode 100644 index 00000000..554ca984 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/clipboard.zig @@ -0,0 +1,36 @@ +/// The clipboard destination for a write. +pub const Location = enum(c_int) { + standard = 0, + selection = 1, + primary = 2, + _, +}; + +/// A single representation of clipboard data. +/// +/// The MIME type and data are borrowed and only valid for the duration of a +/// clipboard write callback. Data is binary-safe. +pub const Content = struct { + mime: []const u8, + data: []const u8, +}; + +/// One atomic clipboard write. +/// +/// Contents are borrowed and only valid for the duration of a clipboard write +/// callback. An empty contents slice clears the destination. +pub const Write = struct { + location: Location, + contents: []const Content, +}; + +/// The result of a clipboard write. +pub const WriteResult = enum(c_int) { + success = 0, + denied = 1, + unsupported = 2, + busy = 3, + invalid_data = 4, + io_error = 5, + _, +}; diff --git a/vendor/libghostty-vt/src/terminal/color.zig b/vendor/libghostty-vt/src/terminal/color.zig index cb1511c3..92de3578 100644 --- a/vendor/libghostty-vt/src/terminal/color.zig +++ b/vendor/libghostty-vt/src/terminal/color.zig @@ -47,6 +47,81 @@ pub const default: Palette = default: { /// Palette is the 256 color palette. pub const Palette = [256]RGB; +/// A parsed palette entry from Ghostty's config "N=COLOR" syntax. +pub const PaletteEntry = struct { + index: u8, + color: RGB, +}; + +/// Parse a palette entry in Ghostty config syntax: "N=COLOR" where N is +/// a palette index 0-255 (decimal, or 0x/0o/0b-prefixed per Zig's +/// parseInt base-0 rules) and COLOR is anything RGB.parse accepts. +/// Whitespace (spaces/tabs) around N and COLOR is ignored. +pub fn parsePaletteEntry(value: []const u8) error{ InvalidFormat, Overflow }!PaletteEntry { + const eql_idx = std.mem.indexOfScalar(u8, value, '=') orelse + return error.InvalidFormat; + const index = std.fmt.parseInt( + u8, + std.mem.trim(u8, value[0..eql_idx], " \t"), + 0, + ) catch |err| switch (err) { + error.Overflow => return error.Overflow, + error.InvalidCharacter => return error.InvalidFormat, + }; + const rgb = try RGB.parse(value[eql_idx + 1 ..]); + return .{ .index = index, .color = rgb }; +} + +test "parsePaletteEntry" { + const testing = std.testing; + + { + const entry = try parsePaletteEntry("0=#AABBCC"); + try testing.expectEqual(@as(u8, 0), entry.index); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color); + } + { + const entry = try parsePaletteEntry("0b1=#014589"); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 1, .g = 69, .b = 137 }, entry.color); + } + { + const entry = try parsePaletteEntry("0o7=#234567"); + try testing.expectEqual(@as(u8, 7), entry.index); + try testing.expectEqual(RGB{ .r = 35, .g = 69, .b = 103 }, entry.color); + } + { + const entry = try parsePaletteEntry("0xF=#ABCDEF"); + try testing.expectEqual(@as(u8, 15), entry.index); + try testing.expectEqual(RGB{ .r = 171, .g = 205, .b = 239 }, entry.color); + } + { + const entry = try parsePaletteEntry("0 = #AABBCC"); + try testing.expectEqual(@as(u8, 0), entry.index); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color); + } + { + const entry = try parsePaletteEntry(" 1= #DDEEFF "); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 221, .g = 238, .b = 255 }, entry.color); + } + { + const entry = try parsePaletteEntry(" 2 = #123456 "); + try testing.expectEqual(@as(u8, 2), entry.index); + try testing.expectEqual(RGB{ .r = 18, .g = 52, .b = 86 }, entry.color); + } + { + const entry = try parsePaletteEntry("1=black"); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, entry.color); + } + + try testing.expectError(error.InvalidFormat, parsePaletteEntry(" ")); + try testing.expectError(error.InvalidFormat, parsePaletteEntry("a")); + try testing.expectError(error.Overflow, parsePaletteEntry("256=#AABBCC")); + try testing.expectError(error.InvalidFormat, parsePaletteEntry("1=notacolor")); +} + /// C-compatible palette type using the extern RGB struct. pub const PaletteC = [256]RGB.C; @@ -441,6 +516,24 @@ pub const RGB = packed struct(u24) { return self.r == other.r and self.g == other.g and self.b == other.b; } + pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ self.r, self.g, self.b }, + ); + } + + pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, self.r) * 257, + @as(u16, self.g) * 257, + @as(u16, self.b) * 257, + }, + ); + } + /// Calculates the contrast ratio between two colors. The contrast /// ration is a value between 1 and 21 where 1 is the lowest contrast /// and 21 is the highest contrast. @@ -541,6 +634,8 @@ pub const RGB = packed struct(u24) { /// Parse a color specification. /// + /// Leading and trailing spaces and tabs are ignored. + /// /// Any of the following forms are accepted: /// /// 1. rgb:<red>/<green>/<blue> @@ -554,38 +649,42 @@ pub const RGB = packed struct(u24) { /// where <red>, <green>, and <blue> are floating point values between /// 0.0 and 1.0 (inclusive). /// - /// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb + /// 3. #rgb, #rrggbb, rgb, rrggbb, #rrrgggbbb, #rrrrggggbbbb /// - /// where `r`, `g`, and `b` are a single hexadecimal digit. - /// These specify a color with 4, 8, 12, and 16 bits of precision - /// per color channel. + /// where `r`, `g`, and `b` are hexadecimal digits. The forms with + /// a leading # specify a color with 4, 8, 12, and 16 bits of + /// precision per color channel. The forms without a leading # are + /// accepted for compatibility with Ghostty config/theme color values. + /// + /// 4. X11 color names pub fn parse(value: []const u8) error{InvalidFormat}!RGB { - if (value.len == 0) { + const input = std.mem.trim(u8, value, " \t"); + if (input.len == 0) { @branchHint(.cold); return error.InvalidFormat; } - if (value[0] == '#') { - switch (value.len) { + if (input[0] == '#') { + switch (input.len) { 4 => return RGB{ - .r = try RGB.fromHex(value[1..2]), - .g = try RGB.fromHex(value[2..3]), - .b = try RGB.fromHex(value[3..4]), + .r = try RGB.fromHex(input[1..2]), + .g = try RGB.fromHex(input[2..3]), + .b = try RGB.fromHex(input[3..4]), }, 7 => return RGB{ - .r = try RGB.fromHex(value[1..3]), - .g = try RGB.fromHex(value[3..5]), - .b = try RGB.fromHex(value[5..7]), + .r = try RGB.fromHex(input[1..3]), + .g = try RGB.fromHex(input[3..5]), + .b = try RGB.fromHex(input[5..7]), }, 10 => return RGB{ - .r = try RGB.fromHex(value[1..4]), - .g = try RGB.fromHex(value[4..7]), - .b = try RGB.fromHex(value[7..10]), + .r = try RGB.fromHex(input[1..4]), + .g = try RGB.fromHex(input[4..7]), + .b = try RGB.fromHex(input[7..10]), }, 13 => return RGB{ - .r = try RGB.fromHex(value[1..5]), - .g = try RGB.fromHex(value[5..9]), - .b = try RGB.fromHex(value[9..13]), + .r = try RGB.fromHex(input[1..5]), + .g = try RGB.fromHex(input[5..9]), + .b = try RGB.fromHex(input[9..13]), }, else => { @@ -595,24 +694,36 @@ pub const RGB = packed struct(u24) { } } - // Check for X11 named colors. We allow whitespace around the edges - // of the color because Kitty allows whitespace. This is not part of - // any spec I could find. - if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; + // Check for X11 named colors. We allow whitespace around the edges. + if (x11_color.map.get(input)) |rgb| return rgb; + + switch (input.len) { + 3 => return RGB{ + .r = try RGB.fromHex(input[0..1]), + .g = try RGB.fromHex(input[1..2]), + .b = try RGB.fromHex(input[2..3]), + }, + 6 => return RGB{ + .r = try RGB.fromHex(input[0..2]), + .g = try RGB.fromHex(input[2..4]), + .b = try RGB.fromHex(input[4..6]), + }, + else => {}, + } - if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { + if (input.len < "rgb:a/a/a".len or !std.mem.eql(u8, input[0..3], "rgb")) { @branchHint(.cold); return error.InvalidFormat; } var i: usize = 3; - const use_intensity = if (value[i] == 'i') blk: { + const use_intensity = if (input[i] == 'i') blk: { i += 1; break :blk true; } else false; - if (value[i] != ':') { + if (input[i] != ':') { @branchHint(.cold); return error.InvalidFormat; } @@ -620,8 +731,8 @@ pub const RGB = packed struct(u24) { i += 1; const r = r: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] + const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end| + input[i..end] else { @branchHint(.cold); return error.InvalidFormat; @@ -636,8 +747,8 @@ pub const RGB = packed struct(u24) { }; const g = g: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] + const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end| + input[i..end] else { @branchHint(.cold); return error.InvalidFormat; @@ -652,9 +763,9 @@ pub const RGB = packed struct(u24) { }; const b = if (use_intensity) - try RGB.fromIntensity(value[i..]) + try RGB.fromIntensity(input[i..]) else - try RGB.fromHex(value[i..]); + try RGB.fromHex(input[i..]); return RGB{ .r = r, @@ -780,6 +891,11 @@ test "RGB.parse" { try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff")); try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff")); try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010")); + try testing.expectEqual(RGB{ .r = 10, .g = 11, .b = 12 }, try RGB.parse("0A0B0C")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFFFFF")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFF")); + try testing.expectEqual(RGB{ .r = 51, .g = 68, .b = 85 }, try RGB.parse("#345")); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, try RGB.parse(" #AABBCC ")); try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black")); try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red")); @@ -790,8 +906,11 @@ test "RGB.parse" { try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen")); try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green")); try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green ")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse("\tForestGreen\t")); // Invalid format + try testing.expectError(error.InvalidFormat, RGB.parse("")); + try testing.expectError(error.InvalidFormat, RGB.parse(" ")); try testing.expectError(error.InvalidFormat, RGB.parse("rgb;")); try testing.expectError(error.InvalidFormat, RGB.parse("rgb:")); try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a")); @@ -807,6 +926,22 @@ test "RGB.parse" { try testing.expectError(error.InvalidFormat, RGB.parse("#ffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); + try testing.expectError(error.InvalidFormat, RGB.parse("#12345")); + try testing.expectError(error.InvalidFormat, RGB.parse("12345")); + try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor")); +} + +test "RGB: encode" { + const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff }; + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try rgb.encodeRgb8(&writer); + try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered()); + + writer = .fixed(&buf); + try rgb.encodeRgb16(&writer); + try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered()); } test "DynamicPalette: init" { diff --git a/vendor/libghostty-vt/src/terminal/compress.zig b/vendor/libghostty-vt/src/terminal/compress.zig new file mode 100644 index 00000000..3b670ed7 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress.zig @@ -0,0 +1,15 @@ +//! Compression primitives used by the terminal. +//! +//! This namespace contains only the representation and codecs. Policy about +//! which pages to compress, when to decommit their resident memory, and when +//! to restore them belongs to `PageList`. + +/// The raw LZ4 block codec used for terminal page memory. +pub const lz4 = @import("compress/lz4.zig"); + +/// A compressed terminal page which retains its original virtual mapping. +pub const Page = @import("compress/Page.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/AGENTS.md b/vendor/libghostty-vt/src/terminal/compress/AGENTS.md new file mode 100644 index 00000000..172891d9 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/AGENTS.md @@ -0,0 +1,90 @@ +# Terminal Compression + +Guidance for the codecs and the compressed page representation +(`Page.zig`) in this directory. These compress terminal page backing +memory (`terminal.Page`). + +## Priorities + +When making tradeoffs, in order: + +1. **Compression ratio on page-shaped data.** Encoded bytes are retained + scrollback memory, and raw `terminal.Page` backing memory is the only + thing we actually compress. Ratio on text files or synthetic data is a + secondary signal. +2. **Decompression throughput.** Pages are compressed once when they go + cold but restored on demand (scrollback access, search, inspection), so + restore latency is felt directly. +3. **Compression throughput.** Runs on idle pages in the background; being + fast is nice, being slow is tolerable. + +## Testing + +- Targeted tests: `zig build test -Dtest-filter=<codec>` +- Prefer `zig build test-lib-vt -Dtest-filter=<codec>` when practical; + this code ships in libghostty-vt. +- Codecs must keep building for `wasm32-freestanding` (libghostty-vt): + no libc, no `src/simd` (Highway) dependencies. Verify with + `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`. +- Every codec needs a differential property suite: round-trip identity, + an independent format walker, wrong-size output rejection, and + corruption/truncation decoding. Keep a light version in normal unit + tests and gate the exhaustive version behind an environment variable so + the default test suite stays fast. + +## Verifying Correctness + +- Decoders must be memory-safe for arbitrary input bytes. Every blind or + wide copy needs a stated margin argument bounding it by the output + buffer; keep those arguments in comments next to the code. +- Writing scratch bytes past a copy's logical end is safe only inside the + output buffer, because in-order decoding rewrites them before any match + can read them back. Do not weaken the exact-size output contract. +- When a change should not alter compressor output, prove it: compare + encoded sizes (or a sequence-count fingerprint) on the same corpus + before and after. Ratio drift is a functional change, not noise. + +## Benchmarking + +- Use `ghostty-bench +page-compression` (see `src/benchmark/AGENTS.md` + for the general workflow). Modes: `compress`, `decompress`, `store`, + and `report` for ratio. +- Build: `zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false` +- The most representative corpus is a raw dump of real page backing + memory, chunked at the page size (400 KiB on ReleaseFast targets). + Supplement with a text corpus and random bytes for worst cases, but + weigh page corpora highest per the priorities above. Keep corpora + outside the repository and reuse identical files across comparisons. +- `ghostty-bench +scrollback-compression` measures the PageList + transitions around the codec rather than the codec itself. +- For fast iteration, keep codecs dependent only on `std` so a standalone + harness can build them directly with `zig build-exe -O ReleaseFast` and + time the codec in-process (report min-of-N, verify round-trips). +- Measure one change at a time and re-measure the final state; run-to-run + noise is a few percent, so re-run before believing small deltas. + +## Performance Notes + +- Real page data decodes as millions of tiny operations (in LZ4: mostly + zero literals plus a 4-18 byte match). Per-item overhead dominates, so + branch-light fast paths with blind fixed-size copies win. +- Wide copies are the only SIMD that pays here. Vectorized compares and + other wide-stride tricks measured as net losses because matches are + short; prefer the simple word loop unless a measurement on page corpora + says otherwise. +- `@memcpy` beats stride loops only for long copies (roughly 64 bytes and + up); call overhead loses below that. + +## LZ4 Specific + +- The codec is `lz4.zig`, an allocation-free raw block (not frame) + implementation. Blocks do not carry their decoded size; callers supply + an exact-size output buffer. +- Tests: `zig build test -Dtest-filter=lz4`. The differential suite is + `lz4_differential.zig`; run the exhaustive version for any codec + change: + `GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"` +- The compressor must keep the standard format restrictions (final five + bytes literal, matches start at least twelve bytes before the end) so + blocks stay consumable by optimized external decoders. The differential + walker checks this. diff --git a/vendor/libghostty-vt/src/terminal/compress/Page.zig b/vendor/libghostty-vt/src/terminal/compress/Page.zig new file mode 100644 index 00000000..b7ad8025 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/Page.zig @@ -0,0 +1,398 @@ +//! A compressed terminal page which retains its resident virtual mapping. +//! +//! Terminal pages have two kinds of state: the large `Page.memory` allocation +//! and a comparatively small `Page` value containing offsets, dimensions, +//! dirty state, and allocator metadata. Not all of the latter state lives in +//! the backing memory, so preserving only the memory bytes is insufficient. +//! We preserve the complete `Page` value instead. This follows the same model +//! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains +//! valid when its memory contents are restored at the same address. +//! +//! The resident memory is deliberately not freed by this type. `PageList` +//! keeps the virtual range allocated while asking the operating system to +//! discard its physical pages. Keeping the range has two useful properties: +//! the embedded page never contains a dangling pointer, and restoring the page +//! does not require a fallible allocation. +//! +//! The intended state transition is: +//! +//! 1. Create this value while the source page is resident. +//! 2. Ask the OS to decommit the source page's memory. +//! 3. Replace the PageList node's resident state with this value. +//! 4. To restore, recommit the retained range and call `restore`. +//! 5. After committing the resident state, call `deinit` to free the encoded +//! bytes. +//! +//! If decommit is unavailable or fails, the caller should deinitialize the +//! compressed candidate and leave the source page resident. This type does not +//! perform any virtual-memory operations itself. +//! +//! The planned native implementation uses `MADV_DONTNEED` on Linux and pairs +//! `MADV_FREE_REUSABLE` with `MADV_FREE_REUSE` on Darwin. Targets without a +//! reliable retained-mapping decommit operation should leave page compression +//! disabled rather than free and later reallocate the resident memory. +const Page = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const TerminalPage = @import("../page.zig").Page; +const lz4 = @import("lz4.zig"); + +/// Complete page metadata together with the retained resident mapping. +/// +/// The bytes in `page.memory` may have been discarded by the OS while this +/// value is in compressed state. They must not be read until `restore` has +/// successfully decoded the page. +page: TerminalPage, + +/// Exact raw LZ4 block for `page.memory`. +encoded: []u8, + +/// Allocator which owns `encoded`. +/// +/// The allocator's backing state must outlive this compressed page. Storing +/// the allocator here lets a PageList node restore and discard its compressed +/// state without needing a reference back to the PageList. +alloc: Allocator, + +/// Return the largest scratch buffer that can produce a useful compressed +/// representation for `raw_len` bytes. +/// +/// This is deliberately smaller than the general LZ4 compression bound. The +/// compressed representation includes an additional slice compared to a +/// resident terminal page, so an encoded block which fills more than this +/// buffer cannot reduce resident memory. Limiting the output here lets a +/// PageList borrow a standard page-pool item as scratch instead of retaining a +/// larger, compression-bound allocation. +/// +/// Callers are expected to reuse the scratch memory when practical. The +/// scratch buffer and hash table are never retained by this type. +pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize { + // Validate the codec's input limit before doing arithmetic based on the + // raw size. The bound itself is intentionally not returned; see above. + _ = try lz4.compressBound(raw_len); + + const representation_overhead = @sizeOf(Page) - @sizeOf(TerminalPage); + if (raw_len <= representation_overhead) return 0; + + // The savings comparison is strict, so reserve one fewer byte than the + // break-even encoded size. + return raw_len - representation_overhead - 1; +} + +/// Create a compressed representation of `source`. +/// +/// `source` is not modified and continues to own its backing allocation. The +/// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes. +/// It must not overlap the source memory. On success, the returned value aliases +/// the source's resident mapping and owns only its exact-sized `encoded` +/// allocation. The allocator and its backing state must remain valid until +/// `deinit` is called. +/// +/// Returns null if retaining the compressed representation would not use less +/// memory than the resident representation. An `OutputTooSmall` result from +/// the codec also means the encoding crossed that break-even point and is +/// therefore returned as null. This comparison includes the in-memory size of +/// both representation structs but does not include allocator metadata or the +/// retained virtual address range. +pub fn init( + alloc: Allocator, + source: *const TerminalPage, + scratch: []u8, + table: *lz4.HashTable, +) (Allocator.Error || lz4.CompressError)!?Page { + const required = try requiredScratch(source.memory.len); + if (scratch.len < required) return error.OutputTooSmall; + if (required == 0) return null; + + const encoded_len = lz4.compress( + source.memory, + scratch[0..required], + table, + ) catch |err| switch (err) { + // The scratch limit is the largest representation worth retaining. + // Running out of room therefore means compression cannot save memory, + // rather than that the caller failed to provide the documented size. + error.OutputTooSmall => return null, + error.InputTooLarge => return err, + }; + + // requiredScratch already accounts for the representation structs. The + // retained virtual range contributes no resident bytes after PageList + // decommits it. + assert(@sizeOf(Page) + encoded_len < + @sizeOf(TerminalPage) + source.memory.len); + + return .{ + .page = source.*, + .encoded = try alloc.dupe(u8, scratch[0..encoded_len]), + .alloc = alloc, + }; +} + +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page) void { + self.alloc.free(self.encoded); + self.* = undefined; +} + +/// Restore the embedded terminal page into its retained resident mapping. +/// +/// The caller must recommit `page.memory` before calling this on platforms +/// which require an explicit recommit operation. The compressed value remains +/// valid whether decoding succeeds or fails, allowing the caller to retry or +/// discard it. On success the returned page aliases the same resident mapping; +/// it does not own a new allocation. +pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { + const result = self.page; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +/// Clone this page into caller-owned memory without restoring its mapping. +/// +/// `memory` must be at least as large as the retained page mapping. Decoding +/// writes only to that buffer, so both the discarded contents of `page.memory` +/// and this value's encoded representation remain unchanged. The returned Page +/// borrows `memory`; the caller must keep it alive for the Page's lifetime and +/// release it directly rather than calling `TerminalPage.deinit`. +pub fn cloneBuf( + self: *const Page, + memory: []align(std.heap.page_size_min) u8, +) lz4.DecompressError!TerminalPage { + assert(memory.len >= self.page.memory.len); + + // Page internals are offsets into the backing buffer, so all metadata can + // be copied verbatim when paired with an equally laid-out mapping. + var result = self.page; + result.memory = memory[0..self.page.memory.len]; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +test "compressed Page retained mapping round trip" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 12, + .rows = 9, + .styles = 8, + .grapheme_bytes = 128, + .string_bytes = 128, + }); + defer resident.deinit(); + resident.size = .{ .cols = 10, .rows = 7 }; + resident.dirty = true; + + // Put state in both the backing memory and the Page value. In particular, + // the style and hyperlink sets retain live counters outside Page.memory. + const rac = resident.getRowAndCell(2, 3); + rac.cell.* = .init('A'); + try resident.appendGrapheme(rac.row, rac.cell, 0x0301); + + const style_id = try resident.styles.add(resident.memory, .{ .flags = .{ + .bold = true, + } }); + rac.cell.style_id = style_id; + rac.row.styled = true; + + const hyperlink_id = try resident.insertHyperlink(.{ + .id = .{ .explicit = "compressed-page" }, + .uri = "https://ghostty.org/docs", + }); + try resident.setHyperlink(rac.row, rac.cell, hyperlink_id); + try resident.verifyIntegrity(testing.allocator); + + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + const memory_ptr = resident.memory.ptr; + const memory_len = resident.memory.len; + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(); + + try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); + try testing.expectEqual(memory_len, compressed.page.memory.len); + try testing.expect(compressed.encoded.len < resident.memory.len); + const expected_encoded = try testing.allocator.dupe(u8, compressed.encoded); + defer testing.allocator.free(expected_encoded); + + // Virtual-memory operations belong to PageList, so clearing the contents + // models a successful decommit: none of the resident bytes remain. + @memset(resident.memory, 0); + + // A clone decodes into independent storage for read-only consumers which + // must not change the compressed representation. In particular, it does + // not recommit or overwrite the retained source mapping. + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + const cloned = try compressed.cloneBuf(clone_memory); + try testing.expect(cloned.memory.ptr != memory_ptr); + try testing.expect(std.mem.allEqual(u8, resident.memory, 0)); + try testing.expectEqualSlices(u8, expected_encoded, compressed.encoded); + try testing.expectEqualSlices(u8, expected, cloned.memory); + try testing.expectEqual(resident.size, cloned.size); + try testing.expect(cloned.dirty); + try cloned.verifyIntegrity(testing.allocator); + + const restored = try compressed.restore(); + try testing.expectEqual(memory_ptr, restored.memory.ptr); + try testing.expectEqual(memory_len, restored.memory.len); + try testing.expectEqualSlices(u8, expected, restored.memory); + try testing.expectEqual(resident.size, restored.size); + try testing.expect(restored.dirty); + try restored.verifyIntegrity(testing.allocator); + + const restored_rac = restored.getRowAndCell(2, 3); + try testing.expectEqualSlices( + u21, + &.{0x0301}, + restored.lookupGrapheme(restored_rac.cell).?, + ); + try testing.expect(restored.styles.get( + restored.memory, + restored_rac.cell.style_id, + ).flags.bold); + + const restored_hyperlink_id = restored.lookupHyperlink(restored_rac.cell).?; + const restored_hyperlink = restored.hyperlink_set.get( + restored.memory, + restored_hyperlink_id, + ); + try testing.expectEqualStrings( + "https://ghostty.org/docs", + restored_hyperlink.uri.slice(restored.memory), + ); +} + +test "compressed Page requires the maximum useful scratch" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 4, .rows = 4 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const required = try requiredScratch(resident.memory.len); + try testing.expectEqual( + resident.memory.len - (@sizeOf(Page) - @sizeOf(TerminalPage)) - 1, + required, + ); + try testing.expect(required < try lz4.compressBound(resident.memory.len)); + + const scratch = try testing.allocator.alloc(u8, required - 1); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + + try testing.expectError(error.OutputTooSmall, Page.init( + testing.allocator, + &resident, + scratch, + &table, + )); + try testing.expectEqualSlices(u8, expected, resident.memory); +} + +test "compressed Page rejects a representation without savings" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 4, + .rows = 4, + .styles = 0, + .grapheme_bytes = 0, + .string_bytes = 0, + .hyperlink_bytes = 0, + }); + defer resident.deinit(); + + var prng = std.Random.DefaultPrng.init(0x4C5A_3402); + prng.random().bytes(resident.memory); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + + try testing.expect((try Page.init( + failing.allocator(), + &resident, + scratch, + &table, + )) == null); + try testing.expect(!failing.has_induced_failure); +} + +test "compressed Page can retry after malformed encoded data" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 8, .rows = 8 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(); + + const full_encoded = compressed.encoded; + const first_byte = full_encoded[0]; + defer { + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + } + compressed.encoded = full_encoded[0..1]; + compressed.encoded[0] = 0xF0; + + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + try testing.expectError( + error.TruncatedInput, + compressed.cloneBuf(clone_memory), + ); + try testing.expectError(error.TruncatedInput, compressed.restore()); + + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + @memset(resident.memory, 0); + const restored = try compressed.restore(); + try testing.expectEqualSlices(u8, expected, restored.memory); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/lz4.zig b/vendor/libghostty-vt/src/terminal/compress/lz4.zig new file mode 100644 index 00000000..a6b07763 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/lz4.zig @@ -0,0 +1,891 @@ +//! An allocation-free implementation of the raw LZ4 block format. +//! +//! LZ4 has two relevant layers: the block format describes the compressed +//! bytes, while the frame format adds headers, sizes, checksums, and support +//! for a stream of blocks. Terminal pages already have their own ownership +//! and metadata, so this implements only blocks. In particular, an encoded +//! block does not contain its decompressed size. The caller must store that +//! separately and provide an exactly sized buffer when decoding. +//! +//! A block is a series of sequences. Each non-final sequence has this shape: +//! +//! token | literal length extensions | literals | offset | match length extensions +//! +//! The token's high nibble contains the literal length and its low nibble +//! contains the match length minus four. A nibble value of 15 means that the +//! length continues in extension bytes at the corresponding point in the +//! sequence. Each extension byte adds to the length; a value of 255 means +//! another byte follows. The literal bytes are copied directly. The two-byte +//! little-endian offset then points backwards in the already decompressed +//! output to the match bytes. +//! +//! The last sequence is special: it contains literals only and ends directly +//! after them. The reference format also requires the last five input bytes to +//! be literals and the final match to begin at least twelve bytes before the +//! end of the input. The compressor observes these restrictions so its output +//! can be consumed by optimized LZ4 decoders which copy in larger units. +//! +//! Compression uses the fast LZ4 strategy: hash each four-byte input sequence +//! and test recent positions as match candidates. Two 16-bit positions fit in +//! each hash-table entry because the format cannot refer further than 64 KiB +//! backwards. Retaining the displaced position recovers useful matches after +//! hash collisions without increasing the fixed 16 KiB workspace. Long runs +//! without matches gradually skip input positions, while matching runs are +//! extended a machine word at a time. The implementation allocates nothing; +//! all input, output, and scratch memory is supplied by the caller. +//! +//! Format reference: +//! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; + +/// Maximum input accepted by the reference LZ4 block API. Keeping the same +/// limit means `compressBound` fits in the integer sizes used by LZ4 callers +/// and gives us the same compatibility boundary as other implementations. +pub const max_input_size: usize = 0x7E000000; + +/// Every LZ4 match represents at least four bytes. The token stores the number +/// of bytes beyond this minimum rather than the full match length. +const min_match = 4; + +/// Number of bytes at the end of a conforming block which must remain literals. +const last_literals = 5; + +/// A match may not begin in the final 12 bytes. This leaves enough room for the +/// minimum match and the required five trailing literals. +const match_find_limit = 12; + +/// We retain one input position for each 12-bit hash. LZ4 refers to this as +/// memory usage 14 because the 4096 entries are four bytes each (16 KiB). +const hash_log = 12; + +/// Multiplicative hash used by the reference LZ4 fast compressor. The high +/// `hash_log` bits provide the table index. +const hash_multiplier: u32 = 2_654_435_761; + +/// Scratch memory used while compressing one block. Each entry packs the low +/// 16 bits of the two most recent input positions for its hash. All-ones marks +/// an empty half; LZ4's 16-bit offset is enough to reconstruct the only useful +/// preceding address from the current position. +/// The table is reset by every call to `compress` and can be reused afterwards. +pub const HashTable = [1 << hash_log]u32; + +/// Errors which can occur while encoding a block. +pub const CompressError = error{ + /// The input exceeds the maximum size supported by the block compressor. + InputTooLarge, + + /// The provided output buffer cannot hold the encoded block. + OutputTooSmall, +}; + +/// Errors which can occur while decoding a block. +pub const DecompressError = error{ + /// The encoded block ended in the middle of a sequence. + TruncatedInput, + + /// A match offset was zero or pointed before the produced output. + InvalidOffset, + + /// A sequence would write beyond the provided output buffer. + OutputTooSmall, + + /// The block ended before filling the exact-size output buffer. + OutputSizeMismatch, +}; + +/// Return the maximum number of bytes needed to encode `input_len` bytes. +/// +/// Incompressible input is represented as one literal run. Every 255 literal +/// bytes can require one extension byte. The additional 16-byte margin covers +/// the token and the format's fixed overhead. Callers can allocate this amount +/// once and reuse it for any block no larger than `input_len`. +pub fn compressBound(input_len: usize) CompressError!usize { + if (input_len > max_input_size) return error.InputTooLarge; + return input_len + input_len / 255 + 16; +} + +/// Compress `input` into a raw LZ4 block in `output`. +/// +/// Returns the initialized length of `output`. The input and output buffers +/// must not overlap. `table` is scratch space and does not need to be +/// initialized by the caller; it is reset before use. +pub fn compress( + input: []const u8, + output: []u8, + table: *HashTable, +) CompressError!usize { + if (input.len > max_input_size) return error.InputTooLarge; + + // All-ones in either packed half means "no previous position". + @memset(table, std.math.maxInt(u32)); + + // `ip` is the current input position, `anchor` is the first literal not yet + // emitted, and `op` is the next output position. A successful match emits + // input[anchor..ip] as literals followed by the match, then moves both input + // positions to the end of that match. + var op: usize = 0; + var anchor: usize = 0; + + // LZ4's format leaves the final five input bytes as literals and starts + // the final match at least twelve bytes before the end. This is not + // required by our safe decoder, but makes blocks compatible with fast + // decoders that rely on the standard format restrictions. Inputs too + // short for any match are emitted below as one literal-only sequence. + if (input.len >= match_find_limit) { + const search_end = input.len - match_find_limit; + const match_end_limit = input.len - last_literals; + var ip: usize = 0; + var search_attempts: usize = 0; + + search: while (ip <= search_end) { + // Hash the next four bytes and replace the table entry + // immediately. Hash collisions are expected, so equality is + // checked below before accepting a saved position as a match. + const sequence = readU32(input, ip); + const hash = hashSequence(sequence); + const candidates = table[hash]; + rememberPosition(table, hash, ip); + + var match_pos = candidatePosition(ip, @truncate(candidates)) orelse + candidatePosition(ip, @truncate(candidates >> 16)) orelse + { + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + + if (readU32(input, match_pos) != sequence) { + // The nearest candidate collided. Fall back to the older + // one, which must additionally match one byte beyond the + // minimum so collision-prone minimum-length matches from + // the stale half are not emitted. + match_pos = older: { + if (candidatePosition( + ip, + @truncate(candidates >> 16), + )) |older| { + if (readU32(input, older) == sequence and + input[older + min_match] == input[ip + min_match]) + { + break :older older; + } + } + + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + } + + // Pull the match backwards into the current literal run. This is + // particularly helpful around aligned cell records. As with + // forward extension, compare words before locating the first + // differing byte. + const match_begin = matchBegin(input, ip, match_pos, anchor); + ip = match_begin.position; + match_pos = match_begin.candidate; + + // We already compared the first four bytes. Continue up to the + // point where the required last five literals begin. `matchEnd` + // compares a machine word at a time before locating the first + // differing byte. + const match_end = matchEnd( + input, + ip + min_match, + match_pos + min_match, + match_end_limit, + ); + + try emitSequence( + output, + &op, + input[anchor..ip], + @intCast(ip - match_pos), + match_end - ip, + ); + + // The main loop jumps over the matched bytes rather than hashing + // every position within them. Seed one position near the end so + // an adjacent repeated record can still refer back into this + // match. The next loop iteration will then seed `match_end` + // normally. + if (match_end >= 2 and match_end - 2 + min_match <= input.len) { + const seed = match_end - 2; + rememberPosition( + table, + hashSequence(readU32(input, seed)), + seed, + ); + } + + ip = match_end; + anchor = ip; + search_attempts = 0; + } + } + + // Whatever remains after the last match is the terminal literal-only + // sequence. For short inputs this is also the only sequence in the block. + try emitLastLiterals(output, &op, input[anchor..]); + return op; +} + +/// Decompress a raw LZ4 block into an exact-size output buffer. +/// +/// Returns `output.len` on success. Both consuming all input and filling all +/// output are required. Raw LZ4 blocks do not carry their decoded size, so this +/// exact-size contract validates the size metadata maintained by the caller. +/// The input and output buffers must not overlap. +pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { + // `ip` and `op` always identify the next unread input byte and the next + // unwritten output byte respectively. + // + // The decoder is written around one observation: almost every sequence + // in real blocks has a short literal run and a short match. Both fast + // paths below copy a fixed number of bytes blindly and let the length + // arithmetic sort out how many of them were meaningful. Writing past a + // run's logical end is safe within the output buffer because decoding + // is strictly in order: every byte past `op` is either rewritten by a + // later copy before anything can read it, or lies beyond the block's + // final length and is never part of the result. The margin conditions + // on the fast paths also subsume the exact bounds checks they replace, + // which keeps decoding of malformed blocks memory-safe. + var ip: usize = 0; + var op: usize = 0; + + while (true) { + // A normal block ends after the literal bytes of its final sequence. + // This also accepts the empty block produced by our compressor, which + // consists of a zero token and no literals. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + const token = input[ip]; + ip += 1; + + // The high nibble and any extension bytes describe the literal run. + // The literals are copied as a side effect of computing the length. + const literal_len: usize = len: { + const nibble: usize = token >> 4; + if (nibble != 15 and + @min(input.len - ip, output.len - op) >= 16) + { + // A run below the extension threshold is at most 14 bytes, + // so with a 16-byte margin on both buffers one wide copy + // covers it. + copyIntAt(u128, output, op, input, ip); + break :len nibble; + } + + // Extended or margin-poor runs take the checked path. Bounds are + // verified before copying so malformed blocks never cause a + // partial read or write. + const len = try decodeLength(input, &ip, nibble); + if (len > input.len - ip) return error.TruncatedInput; + if (len > output.len - op) return error.OutputTooSmall; + @memcpy(output[op..][0..len], input[ip..][0..len]); + break :len len; + }; + ip += literal_len; + op += literal_len; + + // Ending immediately after the literals marks the final sequence. Any + // non-final sequence must continue with an offset and match length. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + if (input.len - ip < 2) return error.TruncatedInput; + const offset = readIntAt(u16, input, ip); + ip += 2; + if (offset == 0 or offset > op) return error.InvalidOffset; + + // The token stores the match length minus the four-byte minimum. As + // with literals, a low nibble of 15 is extended by following bytes. + const match_nibble: usize = token & 0x0F; + + // A match whose length fits its nibble spans at most 18 bytes, so + // three blind copies always cover it. They are overlap-safe when the + // offset is at least a word: each load lies a full word behind the + // store which could observe it, so repeating patterns propagate + // correctly. + if (match_nibble != 15 and offset >= 8 and output.len - op >= 18) { + const match = op - offset; + copyIntAt(u64, output, op, output, match); + copyIntAt(u64, output, op + 8, output, match + 8); + copyIntAt(u16, output, op + 16, output, match + 16); + op += match_nibble + min_match; + continue; + } + + const encoded_match_len = try decodeLength(input, &ip, match_nibble); + const match_len = std.math.add( + usize, + encoded_match_len, + min_match, + ) catch return error.OutputTooSmall; + if (match_len > output.len - op) return error.OutputTooSmall; + + // Match copies may overlap, so this cannot always be one memcpy. + // `copyMatch` expands the common small repeating periods into wide + // stores and uses word copies for larger offsets. + copyMatch(output, op, offset, match_len); + op += match_len; + } +} + +/// Emit one non-final sequence. +/// +/// A sequence starts with a token, followed by optional literal length bytes, +/// the literals themselves, the two-byte offset, and optional match length +/// bytes. This function computes the complete size first so `OutputTooSmall` +/// is reported without partially writing a sequence. +fn emitSequence( + output: []u8, + op: *usize, + literals: []const u8, + offset: u16, + match_len: usize, +) CompressError!void { + assert(match_len >= min_match); + assert(offset > 0); + + const encoded_match_len = match_len - min_match; + + // One byte is always needed for the token and two for the offset. Each + // length may additionally need extension bytes after its token nibble. + const required = 1 + + encodedLengthBytes(literals.len) + literals.len + + 2 + encodedLengthBytes(encoded_match_len); + if (required > output.len - op.*) return error.OutputTooSmall; + + const token_pos = op.*; + op.* += 1; + + // Lengths below 15 fit directly in their nibble. Larger values put 15 in + // the nibble and encode the remainder immediately after the token. + output[token_pos] = (@as(u8, @intCast(@min(literals.len, 15))) << 4) | + @as(u8, @intCast(@min(encoded_match_len, 15))); + + // Literal length extensions precede the literals they describe. + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; + + // Match length extensions follow the offset because this is where the + // decoder expects them in an LZ4 sequence. + writeIntAt(u16, output, op.*, offset); + op.* += 2; + if (encoded_match_len >= 15) + writeLength(output, op, encoded_match_len - 15); +} + +/// Emit the literal-only sequence which terminates every block. +/// +/// There is no offset or match length after these bytes. As with +/// `emitSequence`, capacity is checked before modifying the output. +fn emitLastLiterals( + output: []u8, + op: *usize, + literals: []const u8, +) CompressError!void { + const required = 1 + encodedLengthBytes(literals.len) + literals.len; + if (required > output.len - op.*) return error.OutputTooSmall; + + output[op.*] = @as(u8, @intCast(@min(literals.len, 15))) << 4; + op.* += 1; + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; +} + +/// Return the number of extension bytes needed when a length is represented by +/// a token nibble plus zero or more bytes. An extended length always ends with +/// a byte below 255, so an exact multiple of 255 requires a final zero byte. +fn encodedLengthBytes(encoded_len: usize) usize { + if (encoded_len < 15) return 0; + return (encoded_len - 15) / 255 + 1; +} + +/// Write the portion of a length which did not fit in the token nibble. +/// +/// Each 255 byte means "add 255 and continue". The final byte is always less +/// than 255 and may be zero. +fn writeLength(output: []u8, op: *usize, length_: usize) void { + var length = length_; + while (length >= 255) { + output[op.*] = 255; + op.* += 1; + length -= 255; + } + output[op.*] = @intCast(length); + op.* += 1; +} + +/// Decode a length from its token nibble and any following extension bytes. +/// `ip` is advanced past every consumed extension byte. +fn decodeLength( + input: []const u8, + ip: *usize, + nibble: usize, +) DecompressError!usize { + var length: usize = nibble; + if (nibble != 15) return length; + + while (true) { + if (ip.* >= input.len) return error.TruncatedInput; + const value = input[ip.*]; + ip.* += 1; + length = std.math.add(usize, length, value) catch + return error.TruncatedInput; + if (value != 255) return length; + } +} + +/// Read an unaligned little-endian integer at `position`. +inline fn readIntAt( + comptime Int: type, + input: []const u8, + position: usize, +) Int { + return std.mem.readInt( + Int, + input[position..][0..@sizeOf(Int)], + .little, + ); +} + +/// Write an unaligned little-endian integer at `position`. +inline fn writeIntAt( + comptime Int: type, + output: []u8, + position: usize, + value: Int, +) void { + std.mem.writeInt( + Int, + output[position..][0..@sizeOf(Int)], + value, + .little, + ); +} + +/// Copy one fixed-size integer between non-overlapping byte ranges. +inline fn copyIntAt( + comptime Int: type, + output: []u8, + output_position: usize, + input: []const u8, + input_position: usize, +) void { + writeIntAt( + Int, + output, + output_position, + readIntAt(Int, input, input_position), + ); +} + +/// Read the four-byte sequence used for match finding. Callers only use this +/// where at least four input bytes remain. +inline fn readU32(input: []const u8, pos: usize) u32 { + return readIntAt(u32, input, pos); +} + +/// Add a position to one hash slot and shift the previous newest position into +/// the fallback half. A position whose low bits are 0xFFFF conflicts with the +/// sentinel and is simply not stored. +inline fn rememberPosition(table: *HashTable, hash: usize, position: usize) void { + const low: u16 = @truncate(position); + if (low == std.math.maxInt(u16)) return; + table[hash] = (@as(u32, @truncate(table[hash])) << 16) | low; +} + +/// Recover the nearest preceding position from a stored low half. Modular +/// subtraction directly produces the LZ4 offset within the current window. +inline fn candidatePosition(ip: usize, stored: u16) ?usize { + if (stored == std.math.maxInt(u16)) return null; + const distance: u16 = @as(u16, @truncate(ip)) -% stored; + if (distance == 0) return null; + return ip - distance; +} + +/// Advance through a literal run. The first KiB inspects every byte without +/// maintaining a counter, which keeps ordinary terminal-page searches cheap. +/// Longer runs enable gradually increasing steps for incompressible data. +inline fn advanceSearch( + ip: *usize, + anchor: usize, + attempts: *usize, +) void { + if (attempts.* == 0) { + ip.* += 1; + if (ip.* - anchor == 1024) attempts.* = 1; + return; + } + + attempts.* += 1; + ip.* += 1 + attempts.* / 64; +} + +/// Extend a match backwards without crossing the current literal anchor or the +/// beginning of the candidate. The returned positions preserve their offset. +fn matchBegin( + input: []const u8, + position_: usize, + candidate_: usize, + anchor: usize, +) struct { position: usize, candidate: usize } { + var position = position_; + var candidate = candidate_; + + while (@min(position - anchor, candidate) >= @sizeOf(u64)) { + const position_word = position - @sizeOf(u64); + const candidate_word = candidate - @sizeOf(u64); + const difference = readIntAt(u64, input, position_word) ^ + readIntAt(u64, input, candidate_word); + if (difference != 0) { + const equal_bytes: usize = @intCast(@clz(difference) / 8); + position -= equal_bytes; + candidate -= equal_bytes; + return .{ .position = position, .candidate = candidate }; + } + + position = position_word; + candidate = candidate_word; + } + + while (position > anchor and candidate > 0 and + input[position - 1] == input[candidate - 1]) + { + position -= 1; + candidate -= 1; + } + return .{ .position = position, .candidate = candidate }; +} + +/// Return the first input position where two matching runs differ, or `limit` +/// when they remain equal. Both positions are known to have matched through +/// `min_match` before this is called. +fn matchEnd( + input: []const u8, + position_: usize, + candidate_: usize, + limit: usize, +) usize { + var position = position_; + var candidate = candidate_; + + // Reading as little endian makes the least-significant differing bit map + // to the earliest byte in memory on every target. Unaligned reads are + // lowered appropriately by Zig and require no target-specific intrinsics. + while (limit - position >= @sizeOf(u64)) { + const difference = readIntAt(u64, input, position) ^ + readIntAt(u64, input, candidate); + if (difference != 0) { + const equal_bytes: usize = @intCast(@ctz(difference) / 8); + return position + equal_bytes; + } + + position += @sizeOf(u64); + candidate += @sizeOf(u64); + } + + while (position < limit and input[position] == input[candidate]) { + position += 1; + candidate += 1; + } + return position; +} + +/// Copy one decoded match from `offset` bytes behind `op`. +/// +/// The caller has validated that the match fits: `op + match_len` never +/// exceeds `output.len`. Wide copies may write a few scratch bytes past the +/// match's logical end; as described in `decompress`, that is safe anywhere +/// the write stays inside the output buffer. Every path therefore bounds its +/// wide stores by both the match end and the buffer end, and the bytewise +/// loop at the bottom finishes whatever remains. +fn copyMatch(output: []u8, op_: usize, offset: usize, match_len: usize) void { + var op = op_; + const end = op_ + match_len; + + // A source which ends behind the copy can never overlap it. Long + // distant matches are common in structured pages (repeated rows and + // whole blank regions), and one exact memcpy moves them in cache-line + // units. Shorter matches are not worth the call overhead. + if (offset >= match_len and match_len >= 64) { + @memcpy( + output[op..][0..match_len], + output[op - offset ..][0..match_len], + ); + return; + } + + switch (offset) { + // A period which divides the word size expands into one repeated + // pattern word. Long runs (blank lines, repeated cells) then become + // independent stores, with no load waiting on a preceding store. + // Stores advance by whole words from `op`, which preserves the + // pattern's phase. + 1, 2, 4, 8 => { + const pattern: u64 = switch (offset) { + 1 => @as(u64, output[op - 1]) * 0x0101_0101_0101_0101, + 2 => @as(u64, readIntAt(u16, output, op - 2)) * + 0x0001_0001_0001_0001, + 4 => @as(u64, readIntAt(u32, output, op - 4)) * + 0x0000_0001_0000_0001, + 8 => readIntAt(u64, output, op - 8), + else => unreachable, + }; + + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + writeIntAt(u64, output, op, pattern); + }, + + // Wide copies are overlap-safe for the remaining offsets of at + // least a copy unit: each load lies a full unit behind the store + // which could observe it. Offsets 3, 5, 6, and 7 fall through to + // the bytewise loop; they are rare in real data and word tricks + // for them cost more in complexity than they return. + else => if (offset >= 16) { + const limit = @min(end, output.len -| 15); + while (op < limit) : (op += 16) + copyIntAt(u128, output, op, output, op - offset); + } else if (offset >= 8) { + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + copyIntAt(u64, output, op, output, op - offset); + }, + } + + while (op < end) : (op += 1) output[op] = output[op - offset]; +} + +/// Map a four-byte input sequence to its scratch-table slot. +inline fn hashSequence(sequence: u32) usize { + return @intCast((sequence *% hash_multiplier) >> (32 - hash_log)); +} + +/// Shared round-trip assertion used by the corpus-style tests below. +fn expectRoundTrip(input: []const u8) !void { + const bound = try compressBound(input.len); + const encoded = try testing.allocator.alloc(u8, bound); + defer testing.allocator.free(encoded); + const decoded = try testing.allocator.alloc(u8, input.len); + defer testing.allocator.free(decoded); + + var table: HashTable = undefined; + const encoded_len = try compress(input, encoded, &table); + try testing.expectEqual(input.len, try decompress( + encoded[0..encoded_len], + decoded, + )); + try testing.expectEqualSlices(u8, input, decoded); +} + +test "compressBound" { + try testing.expectEqual(@as(usize, 16), try compressBound(0)); + try testing.expectEqual(@as(usize, 272), try compressBound(255)); + try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1)); +} + +test "literal-only compatibility vectors" { + var empty: [0]u8 = .{}; + try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty)); + + var hello: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x50, 'h', 'e', 'l', 'l', 'o' }, + &hello, + )); + try testing.expectEqualStrings("hello", &hello); + + var fifteen: [15]u8 = undefined; + var encoded: [17]u8 = undefined; + encoded[0] = 0xF0; + encoded[1] = 0; + @memset(encoded[2..], 'x'); + _ = try decompress(&encoded, &fifteen); + try testing.expect(std.mem.allEqual(u8, &fifteen, 'x')); +} + +test "overlapping match compatibility vector" { + // One literal 'a', followed by a four-byte match at distance one. + var output: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x10, 'a', 0x01, 0x00 }, + &output, + )); + try testing.expectEqualStrings("aaaaa", &output); +} + +test "extended overlapping match compatibility vector" { + // One literal followed by a 274-byte match. The match extension is + // encoded as 255 + 0 after the low token nibble's initial 15 bytes. + var output: [275]u8 = undefined; + try testing.expectEqual(@as(usize, output.len), try decompress( + &.{ 0x1F, 'a', 0x01, 0x00, 0xFF, 0x00 }, + &output, + )); + try testing.expect(std.mem.allEqual(u8, &output, 'a')); +} + +test "short offset compatibility vectors" { + + // These blocks end immediately after their match. Besides covering the + // repeating-pattern paths, they verify that the decoder uses exact copies + // when the block does not provide the standard trailing-literal margin. + var offset_two: [6]u8 = undefined; + _ = try decompress(&.{ 0x20, 'a', 'b', 0x02, 0x00 }, &offset_two); + try testing.expectEqualStrings("ababab", &offset_two); + + var offset_three: [9]u8 = undefined; + _ = try decompress( + &.{ 0x32, 'a', 'b', 'c', 0x03, 0x00 }, + &offset_three, + ); + try testing.expectEqualStrings("abcabcabc", &offset_three); + + var offset_four: [8]u8 = undefined; + _ = try decompress( + &.{ 0x40, 'a', 'b', 'c', 'd', 0x04, 0x00 }, + &offset_four, + ); + try testing.expectEqualStrings("abcdabcd", &offset_four); +} + +test "bounded wild copies are overwritten by final literals" { + + // The first sequence's nine-byte match leaves the five final literals + // required by the LZ4 block format. Its logical one-byte tail is copied as + // a word and the following literal sequence overwrites the extra bytes. + var repeated_byte: [15]u8 = undefined; + _ = try decompress( + &.{ + 0x15, 'a', 0x01, 0x00, + 0x50, '1', '2', '3', + '4', '5', + }, + &repeated_byte, + ); + try testing.expectEqualStrings("aaaaaaaaaa12345", &repeated_byte); + + // Exercise the same bounded tail copy with a non-overlapping offset. + var word_offset: [22]u8 = undefined; + _ = try decompress( + &.{ + 0x85, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 0x08, 0x00, + 0x50, '1', '2', '3', '4', '5', + }, + &word_offset, + ); + try testing.expectEqualStrings( + "abcdefghabcdefgha12345", + &word_offset, + ); +} + +test "maximum match offset compatibility vector" { + const literal_len = std.math.maxInt(u16); + const extension_len = (literal_len - 15) / 255 + 1; + const encoded = try testing.allocator.alloc( + u8, + 1 + extension_len + literal_len + 2, + ); + defer testing.allocator.free(encoded); + const output = try testing.allocator.alloc(u8, literal_len + min_match); + defer testing.allocator.free(output); + + var op: usize = 0; + encoded[op] = 0xF0; + op += 1; + writeLength(encoded, &op, literal_len - 15); + for (encoded[op..][0..literal_len], 0..) |*byte, i| + byte.* = @truncate(i); + op += literal_len; + std.mem.writeInt(u16, encoded[op..][0..2], std.math.maxInt(u16), .little); + op += 2; + + try testing.expectEqual(encoded.len, op); + try testing.expectEqual(output.len, try decompress(encoded, output)); + try testing.expectEqualSlices(u8, encoded[1 + extension_len ..][0..4], output[literal_len..]); +} + +test "round trips boundary-sized inputs" { + const lengths = [_]usize{ + 0, 1, 3, 4, 5, 12, 15, 16, 19, + 20, 254, 255, 256, 269, 270, 271, 510, 511, + 512, 65_535, 65_536, 65_537, + }; + + for (lengths) |len| { + const buf = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(buf); + for (buf, 0..) |*byte, i| byte.* = @truncate(i *% 31); + try expectRoundTrip(buf); + } +} + +test "round trips compressible page-sized inputs" { + const page_len = 400 * 1024; + + const zeros = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(zeros); + @memset(zeros, 0); + try expectRoundTrip(zeros); + + const structured = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(structured); + @memset(structured, 0); + for (0..page_len / 8) |i| { + structured[i * 8] = @truncate(' ' + i % 95); + structured[i * 8 + 4] = @truncate((i / 80) % 16); + } + try expectRoundTrip(structured); +} + +test "round trips deterministic random inputs" { + var prng = std.Random.DefaultPrng.init(0x4C5A_3401); + const random = prng.random(); + + for (0..256) |_| { + const len = random.uintLessThan(usize, 32 * 1024); + const input = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(input); + random.bytes(input); + try expectRoundTrip(input); + } +} + +test "compress reports short output" { + const input = "a terminal page needs enough output space"; + var table: HashTable = undefined; + var output: [4]u8 = undefined; + try testing.expectError( + error.OutputTooSmall, + compress(input, &output, &table), + ); +} + +test "decompress rejects malformed blocks" { + var output: [32]u8 = undefined; + + try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output)); + try testing.expectError(error.TruncatedInput, decompress(&.{ 0x10, 'a', 1 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 0, 0 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 2, 0 }, output[0..5])); + try testing.expectError(error.OutputTooSmall, decompress( + &.{ 0x10, 'a', 1, 0 }, + output[0..4], + )); + try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1])); +} + +test { + _ = @import("lz4_differential.zig"); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig b/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig new file mode 100644 index 00000000..bbf3b276 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig @@ -0,0 +1,493 @@ +//! Differential and property tests for the LZ4 block codec. +//! +//! Every generated input must compress into a block which: +//! +//! 1. fits within `compressBound`, +//! 2. is structurally valid LZ4 with the stricter guarantees our +//! compressor documents (final five bytes are literals, matches start +//! at least twelve bytes before the end), verified by an independent +//! walker which shares no code with the codec, +//! 3. decompresses to exactly the original bytes, and +//! 4. is rejected when decompressed into a buffer of the wrong size. +//! +//! Valid blocks are additionally mutated (bit flips, splices, truncations) +//! and fed to the decompressor, which must fail cleanly or succeed, but +//! never read or write out of bounds. The unit-test build enables runtime +//! safety, so any out-of-bounds slice access fails the test. +//! +//! The light suite below runs as a normal unit test and finishes quickly. +//! The exhaustive suite multiplies the same properties across far more +//! sizes, periods, seeds, and mutations; it is slow and therefore skipped +//! unless the environment variable `GHOSTTY_LZ4_SLOW` is set: +//! +//! GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lz4 = @import("lz4.zig"); + +/// Number of trailing block bytes which must be literals, mirroring the +/// documented guarantee of the compressor. Kept as an independent constant +/// so a codec regression cannot silently weaken the check. +const last_literals = 5; + +/// A match may not begin in the final twelve bytes. See `last_literals`. +const match_find_limit = 12; + +/// Sizes around every encoding boundary: token nibble limits (14/15), +/// minimum match and find limits (4/12), length-extension steps (15 + 255k), +/// and power-of-two neighborhoods. +const boundary_sizes = [_]usize{ + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 31, 32, 33, 63, + 64, 65, 254, 255, 256, 269, 270, 271, + 272, 1023, 1024, 4095, 4096, 4097, +}; + +/// Input generators exercising distinct codec behaviors. Every generator is +/// deterministic for a given random state. +const Generator = enum { + /// Uniform random bytes; largely incompressible. + random_bytes, + + /// Runs of one repeated byte with random lengths; period-one matches. + runs, + + /// One repeating pattern; exercises a fixed match period end to end. + periodic, + + /// Eight-byte records with random small payloads and zero padding, with + /// some records repeated; resembles terminal cell memory. + cells, + + /// Dictionary words with separators; text-like literal/match mix. + words, + + /// Mostly zeros with scattered random bytes; long matches with isolated + /// literals. + sparse, + + /// Random segments of all other generators; exercises transitions. + mixed, + + fn fill(gen: Generator, random: std.Random, buf: []u8) void { + switch (gen) { + .random_bytes => random.bytes(buf), + + .runs => { + var i: usize = 0; + while (i < buf.len) { + const run = @min( + random.intRangeAtMost(usize, 1, 300), + buf.len - i, + ); + @memset(buf[i..][0..run], random.int(u8)); + i += run; + } + }, + + .periodic => fillPeriodic( + random, + buf, + random.intRangeAtMost(usize, 1, 40), + ), + + .cells => { + var i: usize = 0; + while (i + 8 <= buf.len) : (i += 8) { + const cell = buf[i..][0..8]; + if (i >= 8 and random.boolean()) { + // Repeat one of the last 256 records. + const back = 8 * random.intRangeAtMost( + usize, + 1, + @min(i / 8, 256), + ); + cell.* = buf[i - back ..][0..8].*; + } else { + @memset(cell, 0); + cell[0] = ' ' + random.uintLessThan(u8, 95); + cell[1] = random.uintLessThan(u8, 4); + } + } + random.bytes(buf[i..]); + }, + + .words => { + const words = [_][]const u8{ + "the", "terminal", "page", "compress", + "row", "cell", "style", "zig", + "lz4", "block", "offset", "match", + "a", "of", "and", "literal", + "0x00", "0xFF", " ", "\r\n", + "-----", "=", "pub fn", "const", + }; + var i: usize = 0; + while (i < buf.len) { + const word = words[random.uintLessThan(usize, words.len)]; + const n = @min(word.len, buf.len - i); + @memcpy(buf[i..][0..n], word[0..n]); + i += n; + if (i < buf.len) { + buf[i] = if (random.boolean()) ' ' else '\n'; + i += 1; + } + } + }, + + .sparse => { + @memset(buf, 0); + if (buf.len == 0) return; + for (0..buf.len / 32 + 1) |_| { + const at = random.uintLessThan(usize, buf.len); + buf[at] = random.int(u8); + } + }, + + .mixed => { + var i: usize = 0; + while (i < buf.len) { + const segment = @min( + random.intRangeAtMost(usize, 1, 2048), + buf.len - i, + ); + const sub = random.enumValue(Generator); + if (sub != .mixed) sub.fill(random, buf[i..][0..segment]); + i += segment; + } + }, + } + } +}; + +/// Fill `buf` with one repeating pattern of the given period. +fn fillPeriodic(random: std.Random, buf: []u8, period: usize) void { + if (buf.len == 0) return; + const head = @min(period, buf.len); + random.bytes(buf[0..head]); + for (head..buf.len) |i| buf[i] = buf[i - period]; +} + +/// Reusable buffers sized for the largest input a suite generates. +const Workspace = struct { + input: []u8, + encoded: []u8, + decoded: []u8, + table: *lz4.HashTable, + + fn init(alloc: Allocator, max_input: usize) !Workspace { + const input = try alloc.alloc(u8, max_input); + errdefer alloc.free(input); + const encoded = try alloc.alloc(u8, try lz4.compressBound(max_input)); + errdefer alloc.free(encoded); + // One extra byte so wrong-size decompression can be tested above + // the exact length as well as below it. + const decoded = try alloc.alloc(u8, max_input + 1); + errdefer alloc.free(decoded); + const table = try alloc.create(lz4.HashTable); + return .{ + .input = input, + .encoded = encoded, + .decoded = decoded, + .table = table, + }; + } + + fn deinit(ws: *Workspace, alloc: Allocator) void { + alloc.free(ws.input); + alloc.free(ws.encoded); + alloc.free(ws.decoded); + alloc.destroy(ws.table); + ws.* = undefined; + } +}; + +/// Compress one input and verify every property promised by the codec. +/// Returns the encoded length so callers can reuse the encoded block. +fn expectCodecProperties(ws: *Workspace, input: []const u8) !usize { + const encoded_len = try lz4.compress(input, ws.encoded, ws.table); + try testing.expect(encoded_len <= try lz4.compressBound(input.len)); + const encoded = ws.encoded[0..encoded_len]; + + try expectValidBlock(encoded, input.len); + + // Exact-size decompression must reproduce the input bit for bit. The + // output is poisoned first so unwritten bytes cannot pass as correct. + const output = ws.decoded[0..input.len]; + @memset(output, 0xAA); + try testing.expectEqual(input.len, try lz4.decompress(encoded, output)); + try testing.expectEqualSlices(u8, input, output); + + // The exact-size contract must reject both smaller and larger buffers. + if (input.len > 0) { + try testing.expectError( + error.OutputTooSmall, + lz4.decompress(encoded, ws.decoded[0 .. input.len - 1]), + ); + } + try testing.expectError( + error.OutputSizeMismatch, + lz4.decompress(encoded, ws.decoded[0 .. input.len + 1]), + ); + + return encoded_len; +} + +/// Structurally validate one encoded block against the LZ4 block format and +/// the stricter guarantees documented by our compressor. This deliberately +/// reimplements the format rather than reusing codec internals. +fn expectValidBlock(encoded: []const u8, raw_len: usize) !void { + var ip: usize = 0; + var op: usize = 0; + var last_match_end: usize = 0; + + while (true) { + // Every sequence, including the final one, starts with a token. + try testing.expect(ip < encoded.len); + const token = encoded[ip]; + ip += 1; + + var literal_len: usize = token >> 4; + if (literal_len == 15) literal_len += try readExtension(encoded, &ip); + try testing.expect(encoded.len - ip >= literal_len); + ip += literal_len; + op += literal_len; + + // The final sequence contains only literals and ends the block. + if (ip == encoded.len) { + try testing.expectEqual(raw_len, op); + if (last_match_end > 0) + try testing.expect(raw_len - last_match_end >= last_literals); + return; + } + + try testing.expect(encoded.len - ip >= 2); + const offset = std.mem.readInt(u16, encoded[ip..][0..2], .little); + ip += 2; + try testing.expect(offset >= 1); + try testing.expect(offset <= op); + + // Our compressor starts matches at least `match_find_limit` bytes + // before the end and never lets one run into the final literals. + try testing.expect(op + match_find_limit <= raw_len); + var match_len: usize = (token & 0x0F) + 4; + if (token & 0x0F == 15) match_len += try readExtension(encoded, &ip); + op += match_len; + try testing.expect(op + last_literals <= raw_len); + last_match_end = op; + } +} + +/// Read one length extension: bytes of 255 accumulate until a terminator +/// below 255, which is included in the sum. +fn readExtension(encoded: []const u8, ip: *usize) !usize { + var total: usize = 0; + while (true) { + try testing.expect(ip.* < encoded.len); + const value = encoded[ip.*]; + ip.* += 1; + total += value; + if (value != 255) return total; + } +} + +/// Decode deterministic corruptions of a valid block. Any result is +/// acceptable except memory unsafety, which the safety-checked test build +/// turns into a failure. Mutations may still form a valid block, so output +/// contents are intentionally not asserted. +fn expectMutationSafety( + ws: *Workspace, + random: std.Random, + encoded_len: usize, + raw_len: usize, + mutations: usize, +) !void { + const original = try testing.allocator.dupe(u8, ws.encoded[0..encoded_len]); + defer testing.allocator.free(original); + + for (0..mutations) |_| { + const block = ws.encoded[0..encoded_len]; + @memcpy(block, original); + + switch (random.uintLessThan(u8, 4)) { + // Flip up to eight random bits. + 0 => for (0..random.intRangeAtMost(usize, 1, 8)) |_| { + const at = random.uintLessThan(usize, block.len); + block[at] ^= @as(u8, 1) << random.int(u3); + }, + // Overwrite one random byte. Token and length bytes are the + // most interesting targets and small blocks are mostly tokens. + 1 => block[random.uintLessThan(usize, block.len)] = + random.int(u8), + // Splice random garbage over a random span. + 2 => { + const at = random.uintLessThan(usize, block.len); + const span = @min( + random.intRangeAtMost(usize, 1, 16), + block.len - at, + ); + random.bytes(block[at..][0..span]); + }, + // Decode a random prefix of the intact block. + else => {}, + } + + const len = if (random.boolean()) + random.uintAtMost(usize, block.len) + else + block.len; + _ = lz4.decompress(block[0..len], ws.decoded[0..raw_len]) catch {}; + } +} + +/// Workload knobs shared by the light and exhaustive suites. +const Budget = struct { + /// Upper bound and step of the contiguous small-size sweep applied to + /// every generator. + sweep_max: usize, + sweep_step: usize, + + /// Number and maximum size of random-parameter inputs. + random_inputs: usize, + random_max: usize, + + /// Explicit match periods checked with `fillPeriodic`. + periods: []const usize, + period_len: usize, + + /// Number of corrupted decode attempts per mutation base block. + mutations: usize, + + fn maxInput(budget: Budget) usize { + return @max( + budget.random_max, + @max(budget.period_len, boundary_sizes[boundary_sizes.len - 1]), + ); + } +}; + +const light_budget: Budget = .{ + .sweep_max = 96, + .sweep_step = 1, + .random_inputs = 24, + .random_max = 32 * 1024, + // One period per copy strategy: byte propagation (3), pattern words + // (1/2/4/8), word strides (9), wide strides (17), plus the 64 KiB + // window edge cases. + .periods = &.{ 1, 2, 3, 4, 8, 9, 17, 65534, 65535, 65536, 65537 }, + .period_len = 160 * 1024, + .mutations = 64, +}; + +const exhaustive_budget: Budget = .{ + .sweep_max = 2048, + .sweep_step = 1, + .random_inputs = 512, + .random_max = 512 * 1024, + .periods = &.{ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 23, 24, 31, 32, 33, + 48, 63, 64, 65, 127, 128, 255, 256, + 257, 4095, 4096, 32768, 65533, 65534, 65535, 65536, + 65537, 65538, + }, + .period_len = 320 * 1024, + .mutations = 4096, +}; + +fn runSuite(budget: Budget, seed: u64) !void { + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + + var ws: Workspace = try .init(testing.allocator, budget.maxInput()); + defer ws.deinit(testing.allocator); + + // Every generator across every boundary size and the small-size sweep. + // Small inputs hit the literal-only format edges: below the minimum + // match sizes, around nibble limits, and around extension steps. + inline for (@typeInfo(Generator).@"enum".fields) |field| { + const gen: Generator = @enumFromInt(field.value); + + for (boundary_sizes) |size| { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + + var size: usize = 0; + while (size <= budget.sweep_max) : (size += budget.sweep_step) { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + } + + // Random generator and size pairs, biased toward interesting sizes by + // squaring so both tiny and large inputs appear. + for (0..budget.random_inputs) |_| { + const gen = random.enumValue(Generator); + const scale = random.float(f64); + const size: usize = @intFromFloat( + scale * scale * @as(f64, @floatFromInt(budget.random_max)), + ); + const input = ws.input[0..size]; + gen.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + + // Reuse a handful of these encodings as corruption bases. + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations / budget.random_inputs + 1, + ); + } + + // Directed match periods, including the 64 KiB window edge where the + // compressor's 16-bit position arithmetic wraps. + for (budget.periods) |period| { + const input = ws.input[0..budget.period_len]; + fillPeriodic(random, input, period); + _ = try expectCodecProperties(&ws, input); + } + + // Dedicated corruption run over a text-like block, plus an exhaustive + // truncation sweep: every prefix of a valid block must decode cleanly + // or fail cleanly. + { + const input = ws.input[0..@min(16 * 1024, budget.random_max)]; + Generator.words.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations, + ); + + for (0..encoded_len) |prefix| { + _ = lz4.decompress( + ws.encoded[0..prefix], + ws.decoded[0..input.len], + ) catch {}; + } + } +} + +test "lz4 differential light" { + try runSuite(light_budget, 0x4C5A_3403); +} + +test "lz4 differential exhaustive" { + // Slow. Enable explicitly, ideally together with a test filter: + // GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" + if (!std.process.hasEnvVarConstant("GHOSTTY_LZ4_SLOW")) + return error.SkipZigTest; + + // Several independent seeds; the suite is deterministic per seed. + for (0..4) |seed| try runSuite(exhaustive_budget, 0x4C5A_4000 + seed); +} diff --git a/vendor/libghostty-vt/src/terminal/device_status.zig b/vendor/libghostty-vt/src/terminal/device_status.zig index 42b4bf01..fd1a9b7e 100644 --- a/vendor/libghostty-vt/src/terminal/device_status.zig +++ b/vendor/libghostty-vt/src/terminal/device_status.zig @@ -7,6 +7,32 @@ pub const ColorScheme = lib.Enum(lib.target, &.{ "dark", }); +/// Maximum number of bytes that `encodeColorSchemeReport` will write. +pub const max_color_scheme_report_encode_size = max: { + var result: usize = 0; + for (@typeInfo(ColorScheme).@"enum".fields) |field| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + encodeColorSchemeReport( + &discarding.writer, + @enumFromInt(field.value), + ) catch unreachable; + result = @max(result, @as(usize, @intCast(discarding.count))); + } + + break :max result; +}; + +/// Encode a color scheme report response for CSI ? 996 n queries. +pub fn encodeColorSchemeReport( + writer: *std.Io.Writer, + scheme: ColorScheme, +) std.Io.Writer.Error!void { + try writer.writeAll(switch (scheme) { + .dark => "\x1B[?997;1n", + .light => "\x1B[?997;2n", + }); +} + /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { const EnumField = std.builtin.Type.EnumField; @@ -72,3 +98,19 @@ const entries: []const Entry = &.{ .{ .name = "cursor_position", .value = 6 }, .{ .name = "color_scheme", .value = 996, .question = true }, }; + +test "encode color scheme report dark" { + try std.testing.expectEqual(@as(usize, 9), max_color_scheme_report_encode_size); + + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .dark); + try std.testing.expectEqualStrings("\x1B[?997;1n", writer.buffered()); +} + +test "encode color scheme report light" { + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .light); + try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered()); +} diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig index a375b4dd..553ea28d 100644 --- a/vendor/libghostty-vt/src/terminal/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/formatter.zig @@ -747,7 +747,7 @@ pub const PageListFormatter = struct { assert(chunk.start < chunk.end); assert(chunk.end > 0); - var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + var formatter: PageFormatter = .init(chunk.node.page(), self.opts); formatter.start_y = chunk.start; formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; @@ -1612,7 +1612,7 @@ test "Page plain single line" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1659,7 +1659,7 @@ test "Page plain single line soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, @@ -1729,7 +1729,7 @@ test "Page plain single wide char" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1820,7 +1820,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.opts.unwrap = true; @@ -1937,7 +1937,7 @@ test "Page plain multiline" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -1988,7 +1988,7 @@ test "Page plain multiline rectangle" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 1; formatter.end_x = 3; @@ -2042,7 +2042,7 @@ test "Page plain multi blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2095,7 +2095,7 @@ test "Page plain trailing blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2148,7 +2148,7 @@ test "Page plain trailing whitespace" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2201,7 +2201,7 @@ test "Page plain trailing whitespace no trim" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .trim = false, @@ -2255,7 +2255,7 @@ test "Page plain with prior trailing state rows" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 2, .cells = 0 }; @@ -2301,7 +2301,7 @@ test "Page plain with prior trailing state cells no wrapped line" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 0, .cells = 3 }; @@ -2346,7 +2346,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Surgically modify the first row to be a wrap continuation const row = page.getRow(0); @@ -2400,7 +2400,7 @@ test "Page plain soft-wrapped without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2449,7 +2449,7 @@ test "Page plain soft-wrapped with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2497,7 +2497,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2551,7 +2551,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2600,7 +2600,7 @@ test "Page plain start_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2647,7 +2647,7 @@ test "Page plain end_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 1; @@ -2694,7 +2694,7 @@ test "Page plain start_y and end_y range" { s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2742,7 +2742,7 @@ test "Page plain start_y out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 30; @@ -2780,7 +2780,7 @@ test "Page plain end_y greater than rows" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 30; @@ -2823,7 +2823,7 @@ test "Page plain end_y less than start_y" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 5; @@ -2862,7 +2862,7 @@ test "Page plain start_x on first row only" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -2904,7 +2904,7 @@ test "Page plain end_x on last row only" { s.nextSlice("first line\r\nsecond line\r\nthird line"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 2; @@ -2957,7 +2957,7 @@ test "Page plain start_x and end_x multiline" { s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3014,7 +3014,7 @@ test "Page plain start_x out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 100; @@ -3052,7 +3052,7 @@ test "Page plain end_x greater than cols" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_x = 100; @@ -3094,7 +3094,7 @@ test "Page plain end_x less than start_x single row" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 10; @@ -3134,7 +3134,7 @@ test "Page plain start_y non-zero ignores trailing state" { s.nextSlice("hello\r\nworld"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -3178,7 +3178,7 @@ test "Page plain start_x non-zero ignores trailing state" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3222,7 +3222,7 @@ test "Page plain start_y and start_x zero uses trailing state" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 0; @@ -3274,7 +3274,7 @@ test "Page plain single line with styling" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -3315,7 +3315,7 @@ test "Page VT single line plain text" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3354,7 +3354,7 @@ test "Page VT single line with bold" { s.nextSlice("\x1b[1mhello\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3400,7 +3400,7 @@ test "Page VT multiple styles" { s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3435,7 +3435,7 @@ test "Page VT with foreground color" { s.nextSlice("\x1b[31mred\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3481,7 +3481,7 @@ test "Page VT with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .vt, @@ -3518,7 +3518,7 @@ test "Page VT multi-line with styles" { s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3555,7 +3555,7 @@ test "Page VT duplicate style not emitted twice" { s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3624,7 +3624,7 @@ test "PageList plain spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3697,7 +3697,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3761,7 +3761,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3822,7 +3822,7 @@ test "PageList VT spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3928,7 +3928,7 @@ test "PageList plain with x offset spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3950,7 +3950,7 @@ test "PageList plain with x offset spanning two pages" { defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); - formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.top_left = .{ .node = first_node, .y = first_node.rows() - 1, .x = 6 }; formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -5175,7 +5175,7 @@ test "Page html with multiple styles" { s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5210,7 +5210,7 @@ test "Page html plain text" { s.nextSlice("hello, world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5243,7 +5243,7 @@ test "Page html with colors" { s.nextSlice("\x1b[31;44mcolored"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5313,7 +5313,7 @@ test "Page html with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html, .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, @@ -5348,7 +5348,7 @@ test "Page html with escaping" { s.nextSlice("<tag>&\"'text"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -5419,7 +5419,7 @@ test "Page html with unicode as numeric entities" { s.nextSlice("╰─ ❯"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5452,7 +5452,7 @@ test "Page html ascii characters unchanged" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5484,7 +5484,7 @@ test "Page html mixed ascii and unicode" { s.nextSlice("test ╰─❯ ok"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5518,7 +5518,7 @@ test "Page VT with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit palette index { @@ -5562,7 +5562,7 @@ test "Page html with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit CSS variable { @@ -5615,7 +5615,7 @@ test "Page VT style reset properly closes styles" { s.nextSlice("\x1b[1mbold\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); builder.clearRetainingCapacity(); var formatter: PageFormatter = .init(page, .vt); @@ -5645,7 +5645,7 @@ test "Page codepoint_map single replacement" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5704,7 +5704,7 @@ test "Page codepoint_map conflicting replacement prefers last" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x', then with 'y' - should prefer last var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5746,7 +5746,7 @@ test "Page codepoint_map replace with string" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with a multi-byte string var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5802,7 +5802,7 @@ test "Page codepoint_map range replacement" { s.nextSlice("abcdefg"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'b' through 'e' with 'X' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5840,7 +5840,7 @@ test "Page codepoint_map multiple ranges" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5884,7 +5884,7 @@ test "Page codepoint_map unicode replacement" { s.nextSlice("hello ⚡ world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace lightning bolt with fire emoji var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5949,7 +5949,7 @@ test "Page codepoint_map with styled formats" { s.nextSlice("\x1b[31mred text\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'e' with 'X' in styled text var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5990,7 +5990,7 @@ test "Page codepoint_map empty map" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Empty map should not change anything var map: std.MultiArrayList(CodepointMap) = .{}; @@ -6032,7 +6032,7 @@ test "Page VT background color on trailing blank cells" { s.nextSlice("\x1b[0m\r\nline2"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); formatter.opts.trim = false; // Don't trim so we can see the trailing behavior @@ -6079,7 +6079,7 @@ test "Page HTML with hyperlinks" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6114,7 +6114,7 @@ test "Page HTML with multiple hyperlinks" { s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6150,7 +6150,7 @@ test "Page HTML with hyperlink escaping" { s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6184,7 +6184,7 @@ test "Page HTML with styled hyperlink" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6219,7 +6219,7 @@ test "Page HTML hyperlink closes style before anchor" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6253,7 +6253,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; diff --git a/vendor/libghostty-vt/src/terminal/hash_map.zig b/vendor/libghostty-vt/src/terminal/hash_map.zig index 5a38cc17..d8feddcd 100644 --- a/vendor/libghostty-vt/src/terminal/hash_map.zig +++ b/vendor/libghostty-vt/src/terminal/hash_map.zig @@ -23,31 +23,49 @@ //! because our terminal page representation is backed by a single large //! allocation so we can give the HashMap a slice of memory to operate in. //! -//! I haven't carefully benchmarked this implementation against other hash -//! map implementations. It's possible using some of the newer variants out -//! there would be better. However, I trust the built-in version is pretty good -//! and its more important to get the terminal page representation working -//! first then we can measure and improve this later if we find it to be a -//! bottleneck. +//! This fork diverges from the stdlib in one significant way: removal uses +//! backward-shift deletion (Knuth vol. 3, section 6.4, algorithm R) rather +//! than tombstones. A fixed-capacity map cannot outgrow tombstone buildup +//! the way an allocating map does, so tombstones require either unbounded +//! probe lengths or periodic in-place rebuilds with subtle bookkeeping. +//! Backward-shift deletion instead restores the table after every removal +//! to the exact state it would be in had the removed key never been +//! inserted. Probe chains are therefore always minimal for the insertion +//! order, there is no fragmentation to repair, and lookup cost depends only +//! on the current load factor. +//! +//! Pointer stability: insertion never moves existing entries, but removal +//! may move *other* entries within a probe cluster. Any key or value +//! pointers previously returned by the map must be considered invalidated +//! by any removal. const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; -const autoHash = std.hash.autoHash; -const math = std.math; const mem = std.mem; const Allocator = mem.Allocator; -const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; const OffsetBuf = @import("size.zig").OffsetBuf; const getOffset = @import("size.zig").getOffset; -pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { - return OffsetHashMap(K, V, AutoContext(K)); +/// The default allows every raw slot to be occupied. Callers whose maps see +/// removal-heavy churn should choose a lower value to bound probe lengths. +pub const default_max_load_percentage: u8 = 100; + +pub fn AutoOffsetHashMap( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return OffsetHashMap(K, V, AutoContext(K), max_load_percentage); } -fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { - return HashMapUnmanaged(K, V, AutoContext(K)); +fn AutoHashMapUnmanaged( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return HashMapUnmanaged(K, V, AutoContext(K), max_load_percentage); } fn AutoContext(comptime K: type) type { @@ -64,12 +82,18 @@ pub fn OffsetHashMap( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); /// This is the pointer-based map that we're wrapping. - pub const Unmanaged = HashMapUnmanaged(K, V, Context); + pub const Unmanaged = HashMapUnmanaged( + K, + V, + Context, + max_load_percentage, + ); pub const Layout = Unmanaged.Layout; /// This is the alignment that the base pointer must have. @@ -81,7 +105,7 @@ pub fn OffsetHashMap( /// HashMap with the given capacity. The base ptr must also be /// aligned to base_align. pub fn layout(cap: Unmanaged.Size) Layout { - return Unmanaged.layoutForCapacity(cap); + return Unmanaged.layoutForSize(cap); } /// Initialize a new HashMap with the given capacity and backing @@ -105,19 +129,22 @@ pub fn OffsetHashMap( } /// Fork of stdlib.HashMap as of Zig 0.12 modified to use offsets for -/// the key/values pointer. The metadata is still a pointer to limit -/// the amount of arithmetic required to access it. See the file comment -/// for full details. +/// the key/values pointer, and backward-shift deletion in place of +/// tombstones. The metadata is still a pointer to limit the amount of +/// arithmetic required to access it. See the file comment for full details. fn HashMapUnmanaged( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); comptime { assert(@alignOf(Metadata) == 1); + assert(max_load_percentage > 0); + assert(max_load_percentage <= 100); } const header_align = @alignOf(Header); @@ -139,10 +166,6 @@ fn HashMapUnmanaged( /// Pointer to the metadata. metadata: ?[*]Metadata = null, - // This is purely empirical and not a /very smart magic constant™/. - /// Capacity of the first grow when bootstrapping the hashmap. - const minimal_capacity = 8; - // This hashmap is specially designed for sizes that fit in a u32. pub const Size = u32; @@ -168,11 +191,9 @@ fn HashMapUnmanaged( size: Size, }; - /// Metadata for a slot. It can be in three states: empty, used or - /// tombstone. Tombstones indicate that an entry was previously used, - /// they are a simple way to handle removal. - /// To this state, we add 7 bits from the slot's key hash. These are - /// used as a fast way to disambiguate between entries without + /// Metadata for a slot. It can be in two states: free or used. + /// To the used state, we add 7 bits from the slot's key hash. These + /// are used as a fast way to disambiguate between entries without /// having to use the equality function. If two fingerprints are /// different, we know that we don't have to compare the keys at all. /// The 7 bits are the highest ones from a 64 bit hash. This way, not @@ -182,28 +203,23 @@ fn HashMapUnmanaged( /// Not using the equality function means we don't have to read into /// the entries array, likely avoiding a cache miss and a potentially /// costly function call. - const Metadata = packed struct { + const Metadata = packed struct(u8) { const FingerPrint = u7; - const free: FingerPrint = 0; - const tombstone: FingerPrint = 1; - - fingerprint: FingerPrint = free, + fingerprint: FingerPrint = 0, used: u1 = 0, - const slot_free = @as(u8, @bitCast(Metadata{ .fingerprint = free })); - const slot_tombstone = @as(u8, @bitCast(Metadata{ .fingerprint = tombstone })); - pub fn isUsed(self: Metadata) bool { return self.used == 1; } - pub fn isTombstone(self: Metadata) bool { - return @as(u8, @bitCast(self)) == slot_tombstone; - } - pub fn isFree(self: Metadata) bool { - return @as(u8, @bitCast(self)) == slot_free; + // A free slot is always the all-zero byte: `fill` sets the + // used bit and removal zeroes the whole byte. Comparing the + // full byte (rather than testing the used bit) lets the + // optimizer fuse this with the fingerprint comparison in + // probe loops into single-byte compares. + return @as(u8, @bitCast(self)) == 0; } pub fn takeFingerprint(hash: Hash) FingerPrint { @@ -216,11 +232,6 @@ fn HashMapUnmanaged( self.used = 1; self.fingerprint = fp; } - - pub fn remove(self: *Metadata) void { - self.used = 0; - self.fingerprint = tombstone; - } }; comptime { @@ -228,6 +239,9 @@ fn HashMapUnmanaged( assert(@alignOf(Metadata) == 1); } + /// Iterates the entries of the map. Any mutation of the map + /// invalidates the iterator: removal may move entries across the + /// iteration cursor. pub const Iterator = struct { hm: *const Self, index: Size = 0, @@ -310,7 +324,7 @@ fn HashMapUnmanaged( pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { if (new_size > self.header().size) { - try self.growIfNeeded(new_size - self.header().size); + try self.checkCapacity(new_size - self.header().size); } } @@ -347,6 +361,13 @@ fn HashMapUnmanaged( return self.header().capacity; } + /// Maximum number of entries the map will hold. This is less than + /// capacity when max_load_percentage is below 100, which keeps free + /// slots in every probe chain and bounds probe lengths. + pub fn maxLoad(self: *const Self) Size { + return maxLoadForCapacity(self.capacity()); + } + pub fn iterator(self: *const Self) Iterator { return .{ .hm = self }; } @@ -391,7 +412,7 @@ fn HashMapUnmanaged( } pub fn putNoClobberContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { assert(!self.containsContext(key, ctx)); - try self.growIfNeeded(1); + try self.checkCapacity(1); self.putAssumeCapacityNoClobberContext(key, value, ctx); } @@ -419,6 +440,9 @@ fn HashMapUnmanaged( pub fn putAssumeCapacityNoClobberContext(self: *Self, key: K, value: V, ctx: Context) void { assert(!self.containsContext(key, ctx)); + // A free slot must exist for the probe below to terminate. + assert(self.header().size < self.capacity()); + const hash = ctx.hash(key); const mask = self.capacity() - 1; var idx = @as(usize, @truncate(hash & mask)); @@ -429,8 +453,7 @@ fn HashMapUnmanaged( metadata = self.metadata.? + idx; } - const fingerprint = Metadata.takeFingerprint(hash); - metadata[0].fill(fingerprint); + metadata[0].fill(Metadata.takeFingerprint(hash)); self.keys()[idx] = key; self.values()[idx] = value; self.header().size += 1; @@ -476,31 +499,22 @@ fn HashMapUnmanaged( } /// If there is an `Entry` with a matching key, it is deleted from - /// the hash map, and then returned from this function. + /// the hash map, and then returned from this function. Removal may + /// move other entries: any previously returned key or value + /// pointers are invalidated. pub fn fetchRemove(self: *Self, key: K) ?KV { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchRemoveContext instead."); return self.fetchRemoveContext(key, undefined); } pub fn fetchRemoveContext(self: *Self, key: K, ctx: Context) ?KV { - return self.fetchRemoveAdapted(key, ctx); - } - pub fn fetchRemoveAdapted(self: *Self, key: anytype, ctx: anytype) ?KV { - if (self.getIndex(key, ctx)) |idx| { - const old_key = &self.keys()[idx]; - const old_val = &self.values()[idx]; - const result = KV{ - .key = old_key.*, - .value = old_val.*, - }; - self.metadata.?[idx].remove(); - old_key.* = undefined; - old_val.* = undefined; - self.header().size -= 1; - return result; - } - - return null; + const idx = self.getIndex(key, ctx) orelse return null; + const result = KV{ + .key = self.keys()[idx], + .value = self.values()[idx], + }; + self.removeByIndexContext(idx, ctx); + return result; } /// Find the index containing the data for the given key. @@ -524,7 +538,7 @@ fn HashMapUnmanaged( } const mask = self.capacity() - 1; const fingerprint = Metadata.takeFingerprint(hash); - // Don't loop indefinitely when there are no empty slots. + // Don't loop indefinitely when there are no free slots. var limit = self.capacity(); var idx = @as(usize, @truncate(hash & mask)); @@ -664,10 +678,10 @@ fn HashMapUnmanaged( return self.getOrPutContextAdapted(key, key_ctx); } pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { - self.growIfNeeded(1) catch |err| { - // If allocation fails, try to do the lookup anyway. - // If we find an existing item, we can return it. - // Otherwise return the error, we could not add another. + self.checkCapacity(1) catch |err| { + // The map is full. Try to do the lookup anyway; if we find + // an existing item, we can return it. Otherwise return the + // error, we could not add another. const index = self.getIndex(key, key_ctx) orelse return err; return GetOrPutResult{ .key_ptr = &self.keys()[index], @@ -704,7 +718,6 @@ fn HashMapUnmanaged( var limit = self.capacity(); var idx = @as(usize, @truncate(hash & mask)); - var first_tombstone_idx: usize = self.capacity(); // invalid index var metadata = self.metadata.? + idx; while (!metadata[0].isFree() and limit != 0) { if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { @@ -724,8 +737,6 @@ fn HashMapUnmanaged( .found_existing = true, }; } - } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) { - first_tombstone_idx = idx; } limit -= 1; @@ -733,11 +744,11 @@ fn HashMapUnmanaged( metadata = self.metadata.? + idx; } - if (first_tombstone_idx < self.capacity()) { - // Cheap try to lower probing lengths after deletions. Recycle a tombstone. - idx = first_tombstone_idx; - metadata = self.metadata.? + idx; - } + // The caller guaranteed capacity for at least one new entry, so + // the probe must have ended at a free slot. Anything else means + // the assume-capacity contract was violated and we would be + // silently overwriting a live entry. + assert(metadata[0].isFree()); metadata[0].fill(fingerprint); const new_key = &self.keys()[idx]; @@ -780,37 +791,74 @@ fn HashMapUnmanaged( return self.getIndex(key, ctx) != null; } - fn removeByIndex(self: *Self, idx: usize) void { - self.metadata.?[idx].remove(); - self.keys()[idx] = undefined; - self.values()[idx] = undefined; + /// Remove the entry at the given index using backward-shift deletion + /// (Knuth vol. 3, section 6.4, algorithm R): rather than marking the + /// slot with a tombstone, restore the table to the state it would be + /// in had the removed key never been inserted. Any entry whose probe + /// sequence passes over the hole is moved into it, which moves the + /// hole further along the cluster, until the cluster ends at a free + /// slot. + fn removeByIndexContext(self: *Self, idx: usize, ctx: Context) void { + const mask: usize = self.capacity() - 1; + const metadata = self.metadata.?; + const keys_ptr = self.keys(); + const values_ptr = self.values(); + + // A completely full table has no free slot to terminate the + // scan, so bound it to one full cycle. That is sufficient: the + // hole only ever moves forward to slots the scan has already + // visited, so each entry needs to be considered exactly once. + var hole = idx; + var j = idx; + var limit = self.capacity() - 1; + while (limit != 0) : (limit -= 1) { + j = (j + 1) & mask; + if (metadata[j].isFree()) break; + + // The entry at `j` may move into the hole only if the hole + // lies on its probe path, i.e. cyclically within [home, j). + // Otherwise the move would place it before its home slot + // and lookups could no longer find it. + const home: usize = @truncate(ctx.hash(keys_ptr[j]) & mask); + if (((hole -% home) & mask) < ((j -% home) & mask)) { + metadata[hole] = metadata[j]; + keys_ptr[hole] = keys_ptr[j]; + values_ptr[hole] = values_ptr[j]; + hole = j; + } + } + + metadata[hole] = .{}; + keys_ptr[hole] = undefined; + values_ptr[hole] = undefined; self.header().size -= 1; } /// If there is an `Entry` with a matching key, it is deleted from /// the hash map, and this function returns true. Otherwise this - /// function returns false. + /// function returns false. Removal may move other entries: any + /// previously returned key or value pointers are invalidated. pub fn remove(self: *Self, key: K) bool { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeContext instead."); return self.removeContext(key, undefined); } pub fn removeContext(self: *Self, key: K, ctx: Context) bool { - return self.removeAdapted(key, ctx); - } - pub fn removeAdapted(self: *Self, key: anytype, ctx: anytype) bool { - if (self.getIndex(key, ctx)) |idx| { - self.removeByIndex(idx); - return true; - } - - return false; + const idx = self.getIndex(key, ctx) orelse return false; + self.removeByIndexContext(idx, ctx); + return true; } /// Delete the entry with key pointed to by key_ptr from the hash map. /// key_ptr is assumed to be a valid pointer to a key that is present - /// in the hash map. + /// in the hash map. Removal may move other entries: any previously + /// returned key or value pointers are invalidated. pub fn removeByPtr(self: *Self, key_ptr: *K) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeByPtrContext instead."); + return self.removeByPtrContext(key_ptr, undefined); + } + pub fn removeByPtrContext(self: *Self, key_ptr: *K, ctx: Context) void { // TODO: replace with pointer subtraction once supported by zig // if @sizeOf(K) == 0 then there is at most one item in the hash // map, which is assumed to exist as key_ptr must be valid. This @@ -820,18 +868,29 @@ fn HashMapUnmanaged( else 0; - self.removeByIndex(idx); + self.removeByIndexContext(idx, ctx); } fn initMetadatas(self: *Self) void { @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); } - fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { - const available = self.capacity() - self.header().size; + /// Returns an error if the map cannot hold `new_count` more entries. + /// This map is fixed-capacity so nothing can be done to make room; + /// the caller must grow the backing memory and rebuild the map. + fn checkCapacity(self: *Self, new_count: Size) Allocator.Error!void { + const available = self.maxLoad() - self.header().size; if (new_count > available) return error.OutOfMemory; } + fn maxLoadForCapacity(cap: Size) Size { + if (cap == 0) return 0; + return @intCast(@divFloor( + @as(u64, cap) * max_load_percentage, + 100, + )); + } + /// The memory layout for the underlying buffer for a given capacity. const Layout = struct { /// The total size of the buffer required. The buffer is expected @@ -888,6 +947,37 @@ fn HashMapUnmanaged( .capacity = new_capacity, }; } + + /// Returns a layout with enough raw slots to hold `new_size` entries + /// at the configured maximum load factor. + pub fn layoutForSize(new_size: Size) Layout { + if (new_size == 0) return layoutForCapacity(0); + + // Scale the requested number of entries up to the raw slot count + // required by the load factor. Widen first so `new_size * 100` + // cannot overflow Size. + const minimum_capacity = std.math.divCeil( + u64, + @as(u64, new_size) * 100, + max_load_percentage, + ) catch unreachable; + + // Capacities must be powers of two, so the largest capacity that + // fits in Size is the highest bit rather than maxInt(Size). + const max_capacity = @as(u64, 1) << + (@typeInfo(Size).int.bits - 1); + if (minimum_capacity > max_capacity) { + return layoutForCapacity(@intCast(max_capacity)); + } + + // Linear probing uses a mask for wraparound, which requires the + // final raw capacity to be rounded up to a power of two. + const raw_capacity = std.math.ceilPowerOfTwo( + u64, + minimum_capacity, + ) catch unreachable; + return layoutForCapacity(@intCast(raw_capacity)); + } }; } @@ -895,8 +985,28 @@ const testing = std.testing; const expect = std.testing.expect; const expectEqual = std.testing.expectEqual; +/// Verify the canonical placement invariant that backward-shift deletion +/// maintains: every used entry is reachable from its home slot without +/// crossing a free slot. This is exactly the property lookups depend on. +fn expectCanonical(map: anytype, ctx: anytype) !void { + const cap = map.capacity(); + const mask = cap - 1; + var used: usize = 0; + for (0..cap) |idx| { + const metadata = map.metadata.?[idx]; + if (!metadata.isUsed()) continue; + used += 1; + + var probe: usize = @truncate(ctx.hash(map.keys()[idx]) & mask); + while (probe != idx) : (probe = (probe + 1) & mask) { + try expect(map.metadata.?[probe].isUsed()); + } + } + try expectEqual(map.count(), used); +} + test "HashMap basic usage" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const alloc = testing.allocator; const cap = 16; @@ -931,7 +1041,7 @@ test "HashMap basic usage" { } test "HashMap ensureTotalCapacity" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -950,8 +1060,8 @@ test "HashMap ensureTotalCapacity" { try testing.expect(initial_capacity == map.capacity()); } -test "HashMap ensureUnusedCapacity with tombstones" { - const Map = AutoHashMapUnmanaged(i32, i32); +test "HashMap ensureUnusedCapacity with removals" { + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -969,7 +1079,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { } test "HashMap clearRetainingCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1000,8 +1110,8 @@ test "HashMap clearRetainingCapacity" { } test "HashMap ensureTotalCapacity with existing elements" { - const Map = AutoHashMapUnmanaged(u32, u32); - const cap = Map.minimal_capacity; + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 8; const alloc = testing.allocator; const layout = Map.layoutForCapacity(cap); @@ -1011,15 +1121,15 @@ test "HashMap ensureTotalCapacity with existing elements" { try map.put(0, 0); try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), Map.minimal_capacity); + try expectEqual(map.capacity(), cap); try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity(65)); try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), Map.minimal_capacity); + try expectEqual(map.capacity(), cap); } test "HashMap remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1057,7 +1167,7 @@ test "HashMap remove" { } test "HashMap reverse removes" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1085,7 +1195,7 @@ test "HashMap reverse removes" { } test "HashMap multiple removes on same metadata" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1128,7 +1238,7 @@ test "HashMap multiple removes on same metadata" { } test "HashMap put and remove loop in random order" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1166,7 +1276,7 @@ test "HashMap put and remove loop in random order" { } test "HashMap put" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1197,7 +1307,7 @@ test "HashMap put" { } test "HashMap put full load" { - const Map = AutoHashMapUnmanaged(usize, usize); + const Map = AutoHashMapUnmanaged(usize, usize, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1213,7 +1323,7 @@ test "HashMap put full load" { } test "HashMap putAssumeCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1248,7 +1358,7 @@ test "HashMap putAssumeCapacity" { } test "HashMap repeat putAssumeCapacity/remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1283,8 +1393,191 @@ test "HashMap repeat putAssumeCapacity/remove" { try expectEqual(map.count(), limit); } +test "HashMap no-clobber move after remove at max load" { + const Context = struct { + pub fn hash(_: @This(), key: u32) u64 { + return key; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const Map = HashMapUnmanaged(u32, u32, Context, 80); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // Fill the map to its maximum load. + const max_load = map.maxLoad(); + for (0..max_load) |i| { + map.putAssumeCapacityNoClobberContext( + @intCast(i), + @intCast(i), + .{}, + ); + } + + // Model a managed-cell move: remove the source and insert the value at + // a destination known to be absent. This must work at maximum load for + // any number of moves since removal genuinely frees a slot. + for (0..100) |i| { + const src: u32 = @intCast(i); + const dst: u32 = @intCast(i + max_load); + try expect(map.removeContext(src, .{})); + map.putAssumeCapacityNoClobberContext(dst, dst, .{}); + + try expectEqual(max_load, map.count()); + try expectEqual(dst, map.getContext(dst, .{}).?); + try expectCanonical(&map, Context{}); + } +} + +test "HashMap removal keeps colliding clusters findable" { + // All keys hash to the same home slot near the end of the table so + // that clusters wrap around the index mask. This exercises the cyclic + // arithmetic in backward-shift deletion. + const Context = struct { + pub fn hash(_: @This(), _: u32) u64 { + return 14; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const Map = HashMapUnmanaged( + u32, + u32, + Context, + default_max_load_percentage, + ); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // Fill half the table: the cluster spans the wraparound point. + for (0..cap / 2) |i| { + map.putAssumeCapacityNoClobberContext(@intCast(i), @intCast(i), .{}); + } + + // Remove from the middle of the cluster and verify all remaining + // entries stay findable after every removal. + var removed: usize = 0; + for ([_]u32{ 3, 0, 7, 4, 1, 6, 2, 5 }) |key| { + try expect(map.removeContext(key, .{})); + removed += 1; + + for (0..cap / 2) |i| { + const k: u32 = @intCast(i); + const v = map.getContext(k, .{}); + if (map.containsContext(k, .{})) { + try expectEqual(k, v.?); + } + } + try expectEqual(cap / 2 - removed, map.count()); + try expectCanonical(&map, Context{}); + } +} + +test "HashMap removal from a completely full table" { + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // A 100% load factor allows filling every raw slot, so removal cannot + // rely on a free slot to terminate its cluster scan. + for (0..cap) |i| { + map.putAssumeCapacityNoClobber(@intCast(i), @intCast(i)); + } + try expectEqual(cap, map.count()); + + // Remove every other key, verifying everything else stays findable. + var expected: usize = cap; + for (0..cap) |i| { + if (i % 2 != 0) continue; + try expect(map.remove(@intCast(i))); + expected -= 1; + try expectEqual(expected, map.count()); + } + + for (0..cap) |i| { + if (i % 2 == 0) { + try expectEqual(null, map.get(@intCast(i))); + } else { + try expectEqual(i, map.get(@intCast(i)).?); + } + } + try expectCanonical(&map, AutoContext(u32){}); +} + +test "HashMap random operations against an oracle" { + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + var oracle: std.AutoHashMapUnmanaged(u32, u32) = .empty; + defer oracle.deinit(alloc); + + var prng = std.Random.DefaultPrng.init(0xdeadbeef); + const random = prng.random(); + + // A small key space forces frequent hits, misses, and re-insertions + // at every load factor from empty to completely full. + const key_space = cap + cap / 2; + for (0..20_000) |_| { + const key = random.uintLessThan(u32, key_space); + switch (random.uintLessThan(u8, 4)) { + 0, 1 => { + const value = random.int(u32); + if (map.put(key, value)) { + try oracle.put(alloc, key, value); + } else |_| { + // Map is full: the oracle must not know this key + // (put on an existing key always succeeds). + try expect(!oracle.contains(key)); + try expectEqual(map.count(), map.capacity()); + } + }, + 2 => try expectEqual( + oracle.remove(key), + map.remove(key), + ), + 3 => try expectEqual(oracle.get(key), map.get(key)), + else => unreachable, + } + + try expectEqual(oracle.count(), map.count()); + } + + // Final full comparison plus the canonical placement invariant. + var it = oracle.iterator(); + while (it.next()) |entry| { + try expectEqual(entry.value_ptr.*, map.get(entry.key_ptr.*).?); + } + try expectCanonical(&map, AutoContext(u32){}); +} + test "HashMap getOrPut" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1313,7 +1606,7 @@ test "HashMap getOrPut" { } test "HashMap basic hash map usage" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1364,7 +1657,7 @@ test "HashMap basic hash map usage" { } test "HashMap ensureUnusedCapacity" { - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1378,7 +1671,7 @@ test "HashMap ensureUnusedCapacity" { } test "HashMap removeByPtr" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1409,7 +1702,7 @@ test "HashMap removeByPtr" { } test "HashMap removeByPtr 0 sized key" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1433,7 +1726,7 @@ test "HashMap removeByPtr 0 sized key" { } test "HashMap repeat fetchRemove" { - const Map = AutoHashMapUnmanaged(u64, void); + const Map = AutoHashMapUnmanaged(u64, void, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1461,7 +1754,11 @@ test "HashMap repeat fetchRemove" { } test "OffsetHashMap basic usage" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1496,7 +1793,11 @@ test "OffsetHashMap basic usage" { } test "OffsetHashMap remake map" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1516,12 +1817,44 @@ test "OffsetHashMap remake map" { } } +test "OffsetHashMap maximum load leaves probe headroom" { + const OffsetMap = AutoOffsetHashMap(u32, u32, 80); + const alloc = testing.allocator; + const requested_size = 16; + const layout = OffsetMap.layout(requested_size); + const buf = try alloc.alignedAlloc( + u8, + OffsetMap.base_align, + layout.total_size, + ); + defer alloc.free(buf); + + const offset_map = OffsetMap.init(.init(buf), layout); + var map = offset_map.map(buf); + + try testing.expect(map.capacity() > requested_size); + try testing.expect(map.maxLoad() >= requested_size); + try testing.expect(map.maxLoad() < map.capacity()); + + for (0..requested_size) |i| try map.put(@intCast(i), @intCast(i)); + for (0..100) |_| { + for (0..requested_size) |i| { + try testing.expect(map.remove(@intCast(i))); + try map.put(@intCast(i), @intCast(i)); + } + } + + for (0..requested_size) |i| { + try testing.expectEqual(@as(u32, @intCast(i)), map.get(@intCast(i))); + } +} + test "layoutForCapacity no overflow for large capacity" { // Test that layoutForCapacity correctly handles large capacities without overflow. // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. // See: https://github.com/ghostty-org/ghostty/issues/9862 - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 diff --git a/vendor/libghostty-vt/src/terminal/hyperlink.zig b/vendor/libghostty-vt/src/terminal/hyperlink.zig index 94f86466..35a16a2a 100644 --- a/vendor/libghostty-vt/src/terminal/hyperlink.zig +++ b/vendor/libghostty-vt/src/terminal/hyperlink.zig @@ -20,7 +20,7 @@ pub const Id = size.HyperlinkCountInt; // The mapping of cell to hyperlink. We use an offset hash map to save space // since its very unlikely a cell is a hyperlink, so its a waste to store // the hyperlink ID in the cell itself. -pub const Map = AutoOffsetHashMap(Offset(Cell), Id); +pub const Map = AutoOffsetHashMap(Offset(Cell), Id, 80); /// A fully decoded hyperlink that may or may not have its /// memory within a page. The memory location of this is dependent diff --git a/vendor/libghostty-vt/src/terminal/kitty/color.zig b/vendor/libghostty-vt/src/terminal/kitty/color.zig index c1072c39..3d2e950c 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/color.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/color.zig @@ -51,6 +51,23 @@ pub const Kind = union(enum) { return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null }; } + /// Returns true when a terminal has built-in state for this key. + /// + /// Unsupported special colors may still be valid Kitty protocol keys, but + /// libghostty-vt cannot report them because Terminal does not store them. + pub fn hasTerminalQueryColor(self: Kind) bool { + return switch (self) { + .palette => true, + .special => |special| switch (special) { + .foreground, + .background, + .cursor, + => true, + else => false, + }, + }; + } + pub fn format( self: Kind, writer: *std.Io.Writer, @@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" { const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }}); try testing.expectEqualStrings("42", actual); } + + try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor()); + try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor()); + try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor()); } diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig index 6659cd31..1f639fab 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig @@ -29,6 +29,7 @@ pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; +pub const nextGeneration = storage.nextGeneration; pub const execute = exec.execute; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig index aa35fbeb..bc0ffb5b 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -149,6 +149,26 @@ pub const Parser = struct { } } + /// Feed a slice of bytes to the parser. This is equivalent to + /// calling feed for each byte in order, but once we're in the data + /// state the remainder of the slice is appended in bulk, avoiding + /// per-byte overhead for large payloads. + pub fn feedSlice(self: *Parser, bytes: []const u8) !void { + var rem = bytes; + while (rem.len > 0) { + if (self.state == .data) { + if (self.data.items.len + rem.len > self.max_bytes) { + return error.OutOfMemory; + } + try self.data.appendSlice(self.arena.child_allocator, rem); + return; + } + + try self.feed(rem[0]); + rem = rem[1..]; + } + } + /// Complete the parsing. This must be called after all the /// bytes have been fed to the parser. /// @@ -987,6 +1007,57 @@ test "transmission command" { try testing.expectEqual(@as(u32, 20), v.height); } +test "feedSlice matches per-byte feed" { + const testing = std.testing; + const alloc = testing.allocator; + + const input = "f=24,s=10,v=20;aGVsbG8gd29ybGQ="; + + var p1 = Parser.init(alloc, 1024 * 1024); + defer p1.deinit(); + for (input) |c| try p1.feed(c); + const c1 = try p1.complete(alloc); + defer c1.deinit(alloc); + + var p2 = Parser.init(alloc, 1024 * 1024); + defer p2.deinit(); + try p2.feedSlice(input); + const c2 = try p2.complete(alloc); + defer c2.deinit(alloc); + + try testing.expect(c1.control == .transmit); + try testing.expect(c2.control == .transmit); + try testing.expectEqualStrings(c1.data, c2.data); +} + +test "feedSlice across slice boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + try p.feedSlice("f=24,s=10"); + try p.feedSlice(",v=20;aGVsbG8g"); + try p.feedSlice("d29ybGQ="); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + + // The payload is base64-decoded on completion. + try testing.expectEqualStrings("hello world", command.data); +} + +test "feedSlice respects max_bytes" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 4); + defer p.deinit(); + + try p.feedSlice("f=24;ab"); + try testing.expectError(error.OutOfMemory, p.feedSlice("cde")); +} + test "transmission ignores 'm' if medium is not direct" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index a6a879e5..6c72ec4e 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -380,7 +380,6 @@ const EncodeableError = Image.Error || Allocator.Error; fn encodeError(r: *Response, err: EncodeableError) void { switch (err) { error.OutOfMemory => r.message = "ENOMEM: out of memory", - error.InternalError => r.message = "EINVAL: internal error", error.InvalidData => r.message = "EINVAL: invalid data", error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", @@ -573,3 +572,87 @@ test "kittygfx no response with no image ID or number load and display" { try testing.expect(resp == null); } } + +test "kittygfx retransmit same id gets fresh image generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit a 1x2 RGB image with id=1. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen1 = storage.imageById(1).?.generation; + try testing.expect(gen1 > 0); + try testing.expectEqual(gen1, storage.generation); + + // Retransmit the same id with identical dimensions/length. The + // (width, height, format, len) tuple is identical, so only the + // generation can reveal that the contents were replaced. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen2 = storage.imageById(1).?.generation; + try testing.expect(gen2 > gen1); + try testing.expectEqual(gen2, storage.generation); +} + +test "kittygfx delete then retransmit same id gets fresh generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit and display, then delete everything (including image + // data), then retransmit the same ID. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen1 = storage.imageById(1).?.generation; + + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=A"); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } + try testing.expect(storage.imageById(1) == null); + const gen_delete = storage.generation; + try testing.expect(gen_delete > gen1); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen2 = storage.imageById(1).?.generation; + try testing.expect(gen2 > gen1); + try testing.expect(gen2 > gen_delete); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig index 8243a632..b6f44fac 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -401,12 +401,6 @@ pub const LoadingImage = struct { return error.InvalidData; } - // Set our time - self.image.transmit_time = std.time.Instant.now() catch |err| { - log.warn("failed to get time: {}", .{err}); - return error.InternalError; - }; - // Everything looks good, copy the image data over. var result = self.image; result.data = try self.data.toOwnedSlice(alloc); @@ -504,6 +498,12 @@ pub const LoadingImage = struct { }; /// Image represents a single fully loaded image. +/// +/// The image data is always fully decoded raw pixels: loading inflates +/// any zlib-compressed payload and decodes PNG into RGBA before an image +/// is completed, so `compression` is always `.none` and `format` is +/// never `.png` for a stored image, and `data.len` always equals +/// `width * height * bytes-per-pixel`. pub const Image = struct { id: u32 = 0, number: u32 = 0, @@ -512,7 +512,14 @@ pub const Image = struct { format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, data: []const u8 = "", - transmit_time: std.time.Instant = undefined, + + /// Unique, monotonically increasing stamp assigned each time an + /// image is added to (or replaced in) an ImageStorage. A changed + /// generation for a given image ID means the image contents may + /// have changed, even if the dimensions and byte length are the + /// same (e.g. a retransmission of the same ID). Stamps order by + /// transmission time. Zero means "never stored". + generation: u64 = 0, /// Set this to true if this image was loaded by a command that /// doesn't specify an ID or number, since such commands should @@ -521,7 +528,6 @@ pub const Image = struct { implicit_id: bool = false, pub const Error = error{ - InternalError, InvalidData, DecompressionFailed, DimensionsRequired, diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index e017d5f7..ba82ea40 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -16,6 +16,53 @@ const Command = command.Command; const log = std.log.scoped(.kitty_gfx); +/// Process-global counter backing all generation stamps (see +/// ImageStorage.generation and Image.generation). This is global rather +/// than per-storage so that stamps are unique across every storage in +/// the process: two mutation events never produce the same value, even +/// across separate screens (main vs. alt), storage resets, or separate +/// terminals. This lets consumers use a generation value alone as a +/// cache key without any ambiguity. +/// +/// Thread-safe because separate terminals may mutate their storages +/// from different threads. On single-threaded targets this lowers to +/// plain operations. +var generation_counter: GenerationCounter = .{}; + +/// Returns the next generation stamp. Stamps are unique and strictly +/// monotonically increasing process-wide, starting at 1 (0 is reserved +/// to mean "never stamped"). +pub fn nextGeneration() u64 { + return generation_counter.next(); +} + +/// Backing implementation for the generation counter. We use a +/// lock-free atomic counter where we can, but not all targets support +/// 64-bit atomic operations (e.g. 32-bit ARM Android), so we fall back +/// to a mutex-protected counter on those. This is a cold path (only +/// invoked on content mutations) so the mutex cost is irrelevant. +/// +/// The pointer-width check is a conservative proxy for 64-bit atomic +/// support: every 64-bit target supports 64-bit atomics, while 32-bit +/// targets may not (per the compiler's atomic operand validation). +const GenerationCounter = if (@bitSizeOf(usize) >= 64) struct { + value: std.atomic.Value(u64) = .init(0), + + fn next(self: *@This()) u64 { + return self.value.fetchAdd(1, .monotonic) + 1; + } +} else struct { + mutex: std.Thread.Mutex = .{}, + value: u64 = 0, + + fn next(self: *@This()) u64 { + self.mutex.lock(); + defer self.mutex.unlock(); + self.value += 1; + return self.value; + } +}; + /// An image storage is associated with a terminal screen (i.e. main /// screen, alt screen) and contains all the transmitted images and /// placements. @@ -27,8 +74,32 @@ pub const ImageStorage = struct { /// purely informational for the renderer and doesn't affect the /// correctness of the program. The renderer must set this to false /// if it cares about this value. + /// + /// Note that dirty is also set by scrolling and resizing (outside + /// of this struct) because those move placement pins, even though + /// the set of images/placements itself is unchanged. See generation + /// for a signal that only tracks content mutations. + /// + /// Invariant: dirty is always set when generation changes + /// (markMutated sets both); dirty set without a generation change + /// means a geometry-only event. dirty: bool = false, + /// Generation stamp of the last content mutation to this storage: + /// any image transmit/replace, placement add, or delete of either. + /// Zero means the storage has never been mutated (and is therefore + /// empty). + /// + /// Unlike dirty, this is NOT updated by scrolling/resizing, so an + /// unchanged generation means the placement set and all image data + /// are identical; only placement geometry (pins) may have moved. + /// Values come from a process-global monotonic counter, so a value + /// observed from any storage never recurs for different content, + /// even across screen switches or storage resets. + /// + /// This field must only be written via markMutated. + generation: u64 = 0, + /// This is the next automatically assigned image ID. We start mid-way /// through the u32 range to avoid collisions with buggy programs. /// TODO: This isn't good enough, it's perfectly legal for programs @@ -80,6 +151,19 @@ pub const ImageStorage = struct { return self.total_limit != 0; } + /// Record a content mutation: marks the storage dirty and assigns a + /// fresh generation stamp. Must be called by anything that changes + /// the set of images or placements (or image contents). + /// + /// Do NOT call this for geometry-only events (scrolling, resizing, + /// screen switches); those must set only the dirty flag directly. + /// Bumping the generation for geometry changes would break the + /// contract that an unchanged generation means unchanged contents. + fn markMutated(self: *ImageStorage) void { + self.dirty = true; + self.generation = nextGeneration(); + } + /// Sets the limit in bytes for the total amount of image data that /// can be loaded. If this limit is lower, this will do an eviction /// if necessary. If the value is zero, then Kitty image protocol will @@ -95,6 +179,7 @@ pub const ImageStorage = struct { const image_limits = self.image_limits; self.deinit(alloc, s); self.* = .{ .image_limits = image_limits }; + self.markMutated(); } // If we re lowering our limit, check if we need to evict. @@ -144,7 +229,12 @@ pub const ImageStorage = struct { gop.value_ptr.* = img; self.total_bytes += img.data.len; - self.dirty = true; + // Stamp the stored image with a fresh generation. This gives + // every add/replace a unique stamp even when the same image ID + // is retransmitted with identical dimensions, so consumers + // (e.g. renderer texture caches) can detect content changes. + self.markMutated(); + gop.value_ptr.generation = self.generation; } /// Add a placement for a given image. The caller must verify in advance @@ -185,7 +275,7 @@ pub const ImageStorage = struct { const gop = try self.placements.getOrPut(alloc, key); gop.value_ptr.* = p; - self.dirty = true; + self.markMutated(); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -207,7 +297,7 @@ pub const ImageStorage = struct { while (it.next()) |kv| { if (kv.value_ptr.number == image_number) { if (newest == null or - kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + kv.value_ptr.generation > newest.?.generation) { newest = kv.value_ptr.*; } @@ -224,6 +314,17 @@ pub const ImageStorage = struct { t: *terminal.Terminal, cmd: command.Delete, ) void { + // Deletes only ever remove placements/images, so comparing counts + // before and after tells us whether anything actually changed. + // Only then do we mark a mutation. This matters because a + // delete-all runs on every screen clear (e.g. `ESC [ 2 J`), and + // we don't want empty clears to dirty the image state or bump + // the generation. + const placements_before = self.placements.count(); + const images_before = self.images.count(); + defer if (self.placements.count() != placements_before or + self.images.count() != images_before) self.markMutated(); + switch (cmd) { .all => |delete_images| { var it = self.placements.iterator(); @@ -245,8 +346,6 @@ pub const ImageStorage = struct { var image_it = self.images.iterator(); while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); } - - self.dirty = true; }, .id => |v| self.deleteById( @@ -341,9 +440,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .row => |v| row: { @@ -373,9 +469,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .z => |v| { @@ -396,9 +489,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .range => |v| range: { @@ -420,9 +510,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, // We don't support animation frames yet so they are successfully @@ -461,9 +548,6 @@ pub const ImageStorage = struct { // If this is specified, then we also delete the image // if it is no longer in use. if (delete_unused) self.deleteIfUnused(alloc, image_id); - - // Mark dirty to force redraw - self.dirty = true; } /// Delete an image if it is unused. @@ -507,9 +591,6 @@ pub const ImageStorage = struct { if (delete_unused) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; } /// Evict image to make space. This will evict the oldest image, @@ -526,7 +607,7 @@ pub const ImageStorage = struct { // bit is fine compared to the megabytes we're looking to save. const Candidate = struct { id: u32, - time: std.time.Instant, + generation: u64, used: bool, }; @@ -554,7 +635,7 @@ pub const ImageStorage = struct { try candidates.append(alloc, .{ .id = img.id, - .time = img.transmit_time, + .generation = img.generation, .used = used, }); } @@ -572,12 +653,14 @@ pub const ImageStorage = struct { ) bool { _ = ctx; - // If they're usage matches, then its based on time. - if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { - .lt => true, - .gt => false, - .eq => lhs.id < rhs.id, - }; + // If their usage matches, then it's based on the + // generation stamp, which orders by transmit time. + // (Stamps are unique but tie-break by ID anyway to + // stay deterministic for hand-built test images.) + if (lhs.used == rhs.used) return if (lhs.generation == rhs.generation) + lhs.id < rhs.id + else + lhs.generation < rhs.generation; // If not used, then its a better candidate return !lhs.used; @@ -585,6 +668,11 @@ pub const ImageStorage = struct { }.lessThan, ); + // Evicting anything is a content mutation. This matters for the + // setLimit path in particular, which doesn't otherwise mark it. + var any_evicted = false; + defer if (any_evicted) self.markMutated(); + // They're in order of best to evict. var evicted: usize = 0; for (candidates.items) |c| { @@ -593,6 +681,7 @@ pub const ImageStorage = struct { while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { self.placements.removeByPtr(entry.key_ptr); + any_evicted = true; } } @@ -604,6 +693,7 @@ pub const ImageStorage = struct { entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); + any_evicted = true; if (evicted > req) return true; } @@ -1362,3 +1452,150 @@ test "storage: aspect ratio calculation when only columns or rows specified" { try testing.expectEqual(@as(u32, 100), calc_size.height); } } + +test "storage: generation stamps on image add and replace" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Fresh storage has generation zero (never mutated). + try testing.expectEqual(@as(u64, 0), s.generation); + + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen1 = s.generation; + try testing.expect(gen1 > 0); + + const img1 = s.imageById(1).?; + try testing.expectEqual(gen1, img1.generation); + + // A second image gets a strictly greater stamp. + try s.addImage(alloc, .{ .id = 2, .width = 1, .height = 1 }); + const gen2 = s.generation; + try testing.expect(gen2 > gen1); + try testing.expectEqual(gen2, s.imageById(2).?.generation); + + // Retransmitting the same image ID (identical dimensions) gets a + // fresh stamp: this is what makes same-sized retransmissions + // detectable by renderers. + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen3 = s.generation; + try testing.expect(gen3 > gen2); + try testing.expectEqual(gen3, s.imageById(1).?.generation); + + // Image 2 kept its stamp. + try testing.expectEqual(gen2, s.imageById(2).?.generation); +} + +test "storage: generation bumps on placement and delete" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + const gen_add = s.generation; + + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen_place = s.generation; + try testing.expect(gen_place > gen_add); + + // Reads don't change the generation. + _ = s.imageById(1); + _ = s.imageByNumber(1); + try testing.expectEqual(gen_place, s.generation); + + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.generation > gen_place); +} + +test "storage: generation bumps when setLimit evicts or disables" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const data = try alloc.dupe(u8, "1234"); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1, .data = data }); + const gen_add = s.generation; + + // Lowering the limit evicts the image and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 1); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_add); + try testing.expectEqual(@as(usize, 0), s.images.count()); + const gen_evict = s.generation; + + // Disabling (limit=0) resets the storage and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 0); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_evict); +} + +test "storage: imageByNumber returns most recently transmitted" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Two images sharing a number: the newest transmission wins, + // regardless of insertion order or clock resolution. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try s.addImage(alloc, .{ .id = 2, .number = 7 }); + try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id); + + // Retransmit the first: it becomes the newest. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); +} + +test "storage: nextGeneration is unique and monotonic" { + const testing = std.testing; + const a = nextGeneration(); + const b = nextGeneration(); + try testing.expect(b > a); + try testing.expect(a > 0); +} + +test "storage: no-op delete does not mark a mutation" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A delete-all on an empty storage (this runs on every screen + // clear) must not dirty the state or bump the generation. + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(!s.dirty); + try testing.expectEqual(@as(u64, 0), s.generation); + + // Same for a delete that matches nothing. + try s.addImage(alloc, .{ .id = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen = s.generation; + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 42 } }); + try testing.expect(!s.dirty); + try testing.expectEqual(gen, s.generation); + + // But a delete that removes something does mark a mutation. + s.delete(alloc, &t, .{ .id = .{ .image_id = 1 } }); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen); +} diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig index 53491a00..af277f97 100644 --- a/vendor/libghostty-vt/src/terminal/main.zig +++ b/vendor/libghostty-vt/src/terminal/main.zig @@ -10,6 +10,7 @@ pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); +pub const clipboard = @import("clipboard.zig"); pub const device_attributes = @import("device_attributes.zig"); pub const device_status = @import("device_status.zig"); pub const focus = @import("focus.zig"); @@ -75,6 +76,9 @@ pub const Attribute = sgr.Attribute; pub const Options = @import("build_options.zig").Options; pub const options = @import("terminal_options"); +/// Whether this target supports terminal page compression. +pub const compression_enabled = @import("mem.zig").canReclaim(.strict); + /// This is set to true when we're building the C library. pub const c_api = if (options.c_abi) @import("c/main.zig") else void; @@ -83,7 +87,9 @@ test { // Internals _ = @import("bitmap_allocator.zig"); + _ = @import("compress.zig"); _ = @import("hash_map.zig"); + _ = @import("mem.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); } diff --git a/vendor/libghostty-vt/src/terminal/mem.zig b/vendor/libghostty-vt/src/terminal/mem.zig new file mode 100644 index 00000000..7fa31480 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/mem.zig @@ -0,0 +1,222 @@ +//! Virtual-memory operations shared by terminal page owners. +//! +//! Terminal pages use page-aligned, page-multiple mappings. This module can +//! discard the physical pages behind one of those mappings without releasing +//! its virtual address range, then prepare the same range for reuse. It does +//! not allocate memory or decide which terminal pages should be discarded. +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; + +const log = std.log.scoped(.terminal_mem); + +/// What guarantee decommit must provide when the OS cannot discard a mapping. +pub const DecommitMode = enum { + /// The dirty prefix must read as zero after this call, even when physical + /// reclamation is unavailable. Bytes after the prefix must already be zero. + zero, + + /// Physical-memory reclamation is required. Do not touch the mapping when + /// reclamation is unsupported or fails; report the failure to the caller. + strict, +}; + +/// Return whether this target can reclaim physical memory for `mode` while +/// retaining the mapping's virtual address range. +/// +/// Test builds support both modes because `decommit` simulates reclamation by +/// clearing the supplied range. Runtime reclamation is intentionally limited +/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory +/// resident; zero mode still provides its documented memset fallback through +/// `decommit` even when this function returns false. +pub inline fn canReclaim(comptime mode: DecommitMode) bool { + // Both modes use the same retained-mapping primitives. Keeping the switch + // exhaustive makes additions to DecommitMode choose target support + // explicitly rather than inheriting it accidentally. + return switch (mode) { + .zero, .strict => supported: { + // Tests never call into the OS because their allocator ranges can + // share mappings with unrelated allocations. `decommit` simulates + // successful reclamation by zeroing the requested range instead, + // so both modes are always available to tests on every target. + if (builtin.is_test) break :supported true; + + // Compression currently retains complete page mappings for its + // lifetime. Limit the initial runtime support to 64-bit address + // spaces where that virtual-memory cost is negligible and where + // the retained-mapping behavior has been validated. + if (builtin.target.ptrBitWidth() != 64) break :supported false; + + // Linux provides MADV_DONTNEED, which immediately discards pages + // from a private anonymous mapping and faults them back as zeroes. + // Zig reaches this through the raw syscall path without libc. + if (builtin.target.os.tag == .linux) break :supported true; + + // Darwin provides the paired MADV_FREE_REUSABLE/FREE_REUSE + // operations used below. Darwin requires libc independently of + // this feature, so using its madvise entry point adds no new + // dependency to libghostty-vt. + if (builtin.target.os.tag.isDarwin()) break :supported true; + + // Other targets have no retained-mapping reclamation contract in + // this module. Zero mode can still clear through its memset + // fallback, but strict callers must leave their mapping resident. + break :supported false; + }, + }; +} + +/// Discard physical pages while retaining a mapping's virtual address range. +/// +/// The complete mapping must be page-aligned and a multiple of the minimum +/// system page size. `dirty_len` identifies the prefix whose contents may be +/// nonzero. Strict mode requires the complete mapping to be dirty because a +/// successful discard invalidates all of its contents. +/// +/// The return value reports whether the OS accepted the reclamation request. +/// Test builds return true after simulating reclamation by zeroing dirty bytes. +/// In zero mode, the requested bytes are guaranteed to be zero regardless of +/// the return value. +pub fn decommit( + comptime mode: DecommitMode, + memory: []align(std.heap.page_size_min) u8, + dirty_len: usize, +) bool { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + assert(dirty_len <= memory.len); + if (comptime mode == .strict) assert(dirty_len == memory.len); + + // Testing allocator ranges may share an OS mapping with unrelated memory, + // so madvise is not safe. Zeroing models the only content guarantee callers + // have after a successful discard. + if (comptime builtin.is_test) { + @memset(memory[0..dirty_len], 0); + return true; + } + + // DONTNEED immediately reclaims private anonymous pages on Linux and + // faults them back as zero-filled pages. We deliberately avoid MADV_FREE: + // it does not reduce RSS until memory pressure and does not guarantee that + // the next read is zero. + if (comptime builtin.os.tag == .linux) { + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.DONTNEED, + )) |_| return true else |err| { + log.warn("madvise(DONTNEED) failed err={}", .{err}); + if (comptime mode == .strict) return false; + // Zero mode falls through to the memset below. + } + } + + // FREE_REUSABLE removes the range from the Darwin process footprint while + // retaining its mapping. Zero mode clears its dirty prefix first because + // the kernel may preserve the contents. Strict mode avoids that write + // because its caller will replace the entire mapping after recommit. + if (comptime builtin.os.tag.isDarwin()) { + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSABLE, + )) |_| return true else |err| { + switch (mode) { + .strict => { + log.warn("madvise(FREE_REUSABLE) failed err={}", .{err}); + return false; + }, + + .zero => { + // Plain FREE can still reclaim the already-zero mapping + // under pressure and does not require a reuse pairing. + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE, + ) catch {}; + return false; + }, + } + } + } + + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + return false; +} + +/// Prepare a mapping previously passed to decommit for reuse. +/// +/// Linux and test builds need no explicit operation. Darwin pairs +/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted +/// to the process again. Failure does not invalidate the retained mapping, so +/// reuse can continue after logging the accounting failure. +pub fn recommit(memory: []align(std.heap.page_size_min) u8) void { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + + if (comptime builtin.is_test) return; + if (comptime builtin.os.tag.isDarwin()) { + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSE, + ) catch |err| { + log.warn("madvise(FREE_REUSE) failed err={}", .{err}); + }; + } +} + +test "decommit with zero fallback clears the dirty prefix" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + + @memset(memory, 0xAA); + _ = decommit(.zero, memory, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + // The tail is already zero by contract, so a partially dirty mapping only + // needs its dirty prefix cleared. + @memset(memory[0..1024], 0xAA); + _ = decommit(.zero, memory, 1024); + try testing.expect(std.mem.allEqual(u8, memory, 0)); +} + +test "strict decommit retains the mapping for recommit" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + @memset(memory, 0xAA); + + const original_ptr = memory.ptr; + const original_len = memory.len; + try testing.expect(decommit(.strict, memory, memory.len)); + try testing.expectEqual(original_ptr, memory.ptr); + try testing.expectEqual(original_len, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + recommit(memory); + @memset(memory, 0xBB); + try testing.expect(std.mem.allEqual(u8, memory, 0xBB)); +} + +test "test builds can reclaim retained mappings" { + const testing = std.testing; + try testing.expect(canReclaim(.zero)); + try testing.expect(canReclaim(.strict)); +} diff --git a/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig index 36cfd7f8..26ce1267 100644 --- a/vendor/libghostty-vt/src/terminal/osc.zig +++ b/vendor/libghostty-vt/src/terminal/osc.zig @@ -156,6 +156,9 @@ pub const Command = union(Key) { kitty_clipboard_protocol: KittyClipboardProtocol, + /// Kitty drag and drop protocol (OSC 72) + kitty_dnd_protocol: KittyDndProtocol, + /// OSC 3008. Hierarchical context signalling (UAPI spec). /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, @@ -164,6 +167,8 @@ pub const Command = union(Key) { pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; + pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. @@ -192,6 +197,7 @@ pub const Command = union(Key) { "conemu_comment", "kitty_text_sizing", "kitty_clipboard_protocol", + "kitty_dnd_protocol", "context_signal", }, ); @@ -344,6 +350,7 @@ pub const Parser = struct { @"52", @"55", @"66", + @"72", @"77", @"104", @"110", @@ -398,10 +405,12 @@ pub const Parser = struct { .kitty_color_protocol => |*v| kitty_color_protocol: { v.deinit(self.alloc orelse break :kitty_color_protocol); }, + .color_operation => |*v| color_operation: { + v.requests.deinit(self.alloc orelse break :color_operation); + }, .change_window_icon, .change_window_title, .clipboard_contents, - .color_operation, .conemu_change_tab_title, .conemu_comment, .conemu_guimacro, @@ -421,6 +430,7 @@ pub const Parser = struct { .show_desktop_notification, .kitty_text_sizing, .kitty_clipboard_protocol, + .kitty_dnd_protocol, .context_signal, => {}, } @@ -691,10 +701,16 @@ pub const Parser = struct { .@"7" => switch (c) { ';' => self.captureTrailing(.fixed), + '2' => self.state = .@"72", '7' => self.state = .@"77", else => self.state = .invalid, }, + .@"72" => switch (c) { + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + .@"77" => switch (c) { '7' => self.state = .@"777", else => self.state = .invalid, @@ -805,6 +821,8 @@ pub const Parser = struct { .@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), + .@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch), + .@"77" => null, .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers.zig b/vendor/libghostty-vt/src/terminal/osc/parsers.zig index a56b38e8..8cb91fb9 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers.zig @@ -9,6 +9,7 @@ pub const hyperlink = @import("parsers/hyperlink.zig"); pub const iterm2 = @import("parsers/iterm2.zig"); pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); +pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig new file mode 100644 index 00000000..35d23906 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -0,0 +1,363 @@ +//! Kitty's drag and drop protocol (OSC 72) +//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ + +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const Terminator = @import("../../osc.zig").Terminator; + +const log = std.log.scoped(.kitty_dnd_protocol); + +pub const OSC = struct { + /// The raw metadata that was received. Parse individual values with `readOption`. + metadata: []const u8, + /// The raw payload. Its meaning and encoding depend on the event type (`t` key). + payload: ?[]const u8, + /// The terminator used for this OSC, so any response can match it. + terminator: Terminator, + + pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { + return key.read(self.metadata); + } +}; + +/// Values for the `t` (event type) metadata key. +pub const EventType = enum { + /// ('a') Terminal registers itself as willing to accept drops. + accept_drops, + /// ('A') Terminal unregisters itself; drops should no longer be forwarded. + stop_accepting_drops, + /// ('m') Pointer is moving over the terminal while a drag is in progress. + /// Carries `x`/`y` cursor position; -1 signals the drag left the window. + drop_move, + /// ('M') Items were dropped onto the terminal. + /// Carries `x`/`y` drop position and `i` (multiplexer session ID). + drop_dropped, + /// ('r') Terminal requests data for a specific MIME type from the drag source. + /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index). + request_data, + /// ('R') Error response to a `request_data` event. + request_error, + /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal). + offer_drag, + /// ('p') Drag source presents the actual payload for a previously requested MIME type. + /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag). + present_data, + /// ('P') Replace the current drag image with a new one. + /// Payload is the image data; `X`/`Y` carry image dimensions in pixels. + change_drag_image, + /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected). + drag_offer_event, + /// ('E') Error on an outgoing drag offer. + drag_offer_error, + /// ('k') URI list data delivered as part of a drag or clipboard transfer. + uri_list_data, + /// ('q') Query terminal capabilities related to the drag-and-drop protocol. + query, + + pub fn init(str: []const u8) ?EventType { + if (str.len != 1) return null; + return switch (str[0]) { + 'a' => .accept_drops, + 'A' => .stop_accepting_drops, + 'm' => .drop_move, + 'M' => .drop_dropped, + 'r' => .request_data, + 'R' => .request_error, + 'o' => .offer_drag, + 'p' => .present_data, + 'P' => .change_drag_image, + 'e' => .drag_offer_event, + 'E' => .drag_offer_error, + 'k' => .uri_list_data, + 'q' => .query, + else => null, + }; + } +}; + +/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct. +pub const Option = enum { + /// Event type. Maps to `EventType`; present in every OSC 72 sequence. + t, + /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow. + m, + /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer + /// (e.g. tmux) can route data to the correct pane. + i, + /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move. + o, + /// Cursor column in cell units (zero-based). -1 signals the drag has left the window. + x, + /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index + /// in some events (e.g. `request_data`). -1 signals the drag has left the window. + y, + /// Pixel offset from the left edge of the cell; also used as image width + /// (with `change_drag_image`) or as a symlink/directory marker. + X, + /// Pixel offset from the top edge of the cell; also used as image height + /// (with `change_drag_image`) or as a parent directory handle. + Y, + + pub fn Type(comptime key: Option) type { + return switch (key) { + .t => EventType, + // The spec uses 32-bit signed or unsigned; we standardize on + // i32 because the location keys legitimately take -1 (drag + // leaves the window) and other keys never exceed i32 range. + .m, .i, .o, .x, .y, .X, .Y => i32, + }; + } + + pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() { + const name = @tagName(key); + + const value: []const u8 = value: { + var pos: usize = 0; + while (pos < metadata.len) { + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + if (pos >= metadata.len) return null; + + // Case-sensitive match: x and X must not be confused. + if (!std.mem.startsWith(u8, metadata[pos..], name)) { + pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; + pos += 1; + continue; + } + pos += name.len; + + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + if (pos >= metadata.len) return null; + if (metadata[pos] != '=') return null; + + const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; + const start = pos + 1; + break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); + } + return null; + }; + + return switch (key) { + .t => .init(value), + .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, + }; + } +}; + +test "OSC 72: metadata only, no payload" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expect(cmd.kitty_dnd_protocol.payload == null); +} + +test "OSC 72: metadata and empty payload" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: metadata and non-empty payload" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a:i=5;text/plain text/uri-list"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqualStrings("t=a:i=5", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: readOption .t valid event types" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const cases = .{ + .{ "72;t=a", EventType.accept_drops }, + .{ "72;t=A", EventType.stop_accepting_drops }, + .{ "72;t=m", EventType.drop_move }, + .{ "72;t=M", EventType.drop_dropped }, + .{ "72;t=r", EventType.request_data }, + .{ "72;t=R", EventType.request_error }, + .{ "72;t=o", EventType.offer_drag }, + .{ "72;t=p", EventType.present_data }, + .{ "72;t=P", EventType.change_drag_image }, + .{ "72;t=e", EventType.drag_offer_event }, + .{ "72;t=E", EventType.drag_offer_error }, + .{ "72;t=k", EventType.uri_list_data }, + .{ "72;t=q", EventType.query }, + }; + + inline for (cases) |case| { + p.deinit(); + p = .init(testing.allocator); + for (case[0]) |ch| p.next(ch); + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?); + } +} + +test "OSC 72: readOption .t unknown value returns null" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=z"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null); +} + +test "OSC 72: readOption integer keys" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?); + try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); + try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?); + try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?); + try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); + try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?); + try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?); +} + +test "OSC 72: readOption negative sentinel (-1 for drag leave)" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=m:x=-1:y=-1"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?); + try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?); +} + +test "OSC 72: readOption case-sensitive key matching" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + // x=10 must not be returned when asking for .X + const input = "72;x=10:Y=200"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); + try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null); +} + +test "OSC 72: readOption absent key returns null" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null); +} + +test "OSC 72: readOption malformed integer returns null" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;x=notanumber"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); +} + +test "OSC 72: BEL terminator recorded" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=q"; + for (input) |ch| p.next(ch); + + const cmd = p.end(0x07).?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel); +} + +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"72"); + + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; + + const data = cap.trailing(); + + const metadata: []const u8, const payload: ?[]const u8 = result: { + const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; + break :result .{ data[0..sep], data[sep + 1 .. data.len] }; + }; + + parser.command = .{ + .kitty_dnd_protocol = .{ + .metadata = metadata, + .payload = payload, + .terminator = .init(terminator_ch), + }, + }; + + return &parser.command; +} diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig index 25152d7c..97212fbb 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig @@ -60,6 +60,16 @@ pub const Command = struct { } }; +// ClickEvents can either be a click_events=1 or click_events=2. +// The click_events=1 sends a click event with the absolute coordinates +// of the click. +// The click_events=2 sends a click event with the coordinates of the click +// relative to the prompt area. +// See https://github.com/ghostty-org/ghostty/issues/10865 and +// https://github.com/kovidgoyal/kitty/issues/9500 +// for further details. +pub const ClickEvents = enum { absolute, relative }; + pub const Option = enum { aid, cl, @@ -102,7 +112,7 @@ pub const Option = enum { .err => []const u8, .redraw => Redraw, .special_key => bool, - .click_events => bool, + .click_events => ClickEvents, .cmdline => []const u8, .cmdline_url => []const u8, .exit_code => i32, @@ -200,7 +210,12 @@ pub const Option = enum { .last else null, - .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .click_events => if (value.len == 1) switch (value[0]) { + '1' => .absolute, + '2' => .relative, + else => null, + } else null, + .special_key => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -1249,9 +1264,10 @@ test "Option.read special_key" { test "Option.read click_events" { const testing = std.testing; - try testing.expect(Option.click_events.read("click_events=1").? == true); - try testing.expect(Option.click_events.read("click_events=0").? == false); try testing.expect(Option.click_events.read("click_events=yes") == null); + try testing.expect(Option.click_events.read("click_events=0") == null); + try testing.expect(Option.click_events.read("click_events=1").? == .absolute); + try testing.expect(Option.click_events.read("click_events=2").? == .relative); } test "Option.read exit_code" { diff --git a/vendor/libghostty-vt/src/terminal/page.zig b/vendor/libghostty-vt/src/terminal/page.zig index 0942e260..ca6d9854 100644 --- a/vendor/libghostty-vt/src/terminal/page.zig +++ b/vendor/libghostty-vt/src/terminal/page.zig @@ -92,7 +92,11 @@ const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; -const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +const GraphemeMap = AutoOffsetHashMap( + Offset(Cell), + Offset(u21).Slice, + hash_map.default_max_load_percentage, +); /// The allocator used for shared utf8-encoded strings within a page. /// Note the chunk size below is the minimum size of a single allocation @@ -145,6 +149,15 @@ pub const Page = struct { @alignOf(Cell), StyleSet.base_align.toByteUnits(), ) == 0); + + // The PageList memory pool requires that initBuf overwrites at + // least the first pointer-size bytes of the backing buffer: + // std.heap.MemoryPool stores its free list node there when a + // page buffer is returned to it, and pool reuse skips zeroing + // in release builds. This holds because the rows array is at + // offset 0 (see layout), a page always has at least one row, + // and initBuf fully rewrites every row. + assert(@sizeOf(Row) >= @sizeOf(usize)); } /// The backing memory for the page. A page is always made up of a @@ -238,6 +251,13 @@ pub const Page = struct { /// It is up to the caller to not call deinit on these pages. pub inline fn initBuf(buf: OffsetBuf, l: Layout) Page { const cap = l.capacity; + + // A page must always have at least one row. Aside from being + // useless otherwise, the row initialization below must always + // overwrite the start of the buffer for pool reuse. See the + // comptime assert at the top of Page. + assert(cap.rows > 0); + const rows = buf.member(Row, l.rows_start); const cells = buf.member(Cell, l.cells_start); @@ -754,11 +774,11 @@ pub const Page = struct { } } - // The hyperlink_map capacity in layout() is computed as: - // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2) - // We need enough hyperlink_bytes so that when layout() computes - // the map capacity, it can accommodate all hyperlink cells. This - // is unit tested. + // layout() requests `hyperlink_count * hyperlink_cell_multiplier` + // usable map entries. The map layout adds load-factor headroom and + // rounds the raw slot count to a power of two. We need enough + // hyperlink_bytes for that requested entry count to accommodate all + // hyperlink cells. This is unit tested. const hyperlink_cap = cap: { const hyperlink_count = id_set.count(); const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count); @@ -1461,7 +1481,7 @@ pub const Page = struct { const entry = map.getEntry(src_offset).?; const value = entry.value_ptr.*; map.removeByPtr(entry.key_ptr); - map.putAssumeCapacity(dst_offset, value); + map.putAssumeCapacityNoClobber(dst_offset, value); // NOTE: We must not set src/dst.hyperlink here because this // function is used in various cases where we swap cell contents @@ -1478,7 +1498,7 @@ pub const Page = struct { /// Returns the hyperlink capacity for the page. This isn't the byte /// size but the number of unique cells that can have hyperlink data. pub inline fn hyperlinkCapacity(self: *const Page) usize { - return self.hyperlink_map.map(self.memory).capacity(); + return self.hyperlink_map.map(self.memory).maxLoad(); } /// Set the graphemes for the given cell. This asserts that the cell @@ -1609,7 +1629,7 @@ pub const Page = struct { const entry = map.getEntry(src_offset).?; const value = entry.value_ptr.*; map.removeByPtr(entry.key_ptr); - map.putAssumeCapacity(dst_offset, value); + map.putAssumeCapacityNoClobber(dst_offset, value); } /// Clear the graphemes for a given cell. @@ -1703,6 +1723,11 @@ pub const Page = struct { /// and rows size. pub inline fn layout(cap: Capacity) Layout { const rows_count: usize = @intCast(cap.rows); + + // The rows array must stay at offset 0: the PageList memory + // pool relies on initBuf overwriting the first bytes of a + // reused page buffer, which hold the pool's free list node. + // See the comptime assert at the top of Page. const rows_start = 0; const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); @@ -1744,7 +1769,7 @@ pub const Page = struct { u32, hyperlink_count * hyperlink_cell_multiplier, ) orelse break :count std.math.maxInt(u32); - break :count std.math.ceilPowerOfTwoAssert(u32, mult); + break :count mult; }; const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align.toByteUnits()); @@ -1988,6 +2013,11 @@ pub const Row = packed struct(u64) { prompt_continuation = 2, }; + /// The backing integer of this packed struct. Prefer this over + /// hardcoding the integer type so that code is resilient to the + /// size changing. + pub const Backing = @typeInfo(Row).@"struct".backing_integer.?; + /// C ABI type. pub const C = u64; @@ -2100,6 +2130,11 @@ pub const Cell = packed struct(u64) { prompt = 2, }; + /// The backing integer of this packed struct. Prefer this over + /// hardcoding the integer type so that code is resilient to the + /// size changing. + pub const Backing = @typeInfo(Cell).@"struct".backing_integer.?; + /// C ABI type. pub const C = u64; @@ -2194,6 +2229,270 @@ pub const Cell = packed struct(u64) { } }; +/// Returns a mask with all bits set for the given fields of the packed +/// struct T, used for masked compares of raw backing-integer values +/// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation +/// detail of `Mask`, which is the public API built on top of this. +fn fieldMask( + comptime T: type, + comptime fields: []const []const u8, +) @typeInfo(T).@"struct".backing_integer.? { + // Backing int of the packed struct + const Int = @typeInfo(T).@"struct".backing_integer.?; + + var mask: Int = 0; + inline for (fields) |field| { + // The type that fits all the bits we need to set. + const Ones = std.meta.Int( + .unsigned, + @bitSizeOf(@FieldType(T, field)), + ); + + // Mask out the ones + mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field); + } + + return mask; +} + +/// A comptime-generated helper for classifying and comparing packed +/// struct values (e.g. Row, Cell) in bulk, using masked compares of +/// their raw backing integers. +/// +/// Masked compares are the key to making bulk row/cell processing fast. +/// Rows and cells are small packed structs specifically so that a single +/// integer load observes every field at once. A masked compare can then +/// answer a multi-field question with one AND and one compare, instead +/// of extracting and branching on each field individually (each packed +/// field access compiles to its own shift/mask). Just as importantly, +/// the integer form vectorizes trivially: `@splat` the mask and expected +/// value, and whole groups of rows or cells can be classified with a +/// few SIMD instructions. +/// +/// Some real examples of this in use: +/// +/// - Terminal print fast path: a cell can be overwritten by the +/// simple/fast path only if its content tag is a plain codepoint, +/// it has no style, isn't wide, and isn't a hyperlink. Masking +/// with those fields and comparing against a template answers all +/// four questions in one compare per cell. +/// +/// - Render state updates: a cell whose masked +/// `{content_tag, style_id}` bits are zero is a plain cell that +/// needs no managed-memory handling, so a vector OR-reduce can +/// skip entire groups of plain cells at once. Similarly, cells +/// whose masked bits equal the first cell's form a run sharing one +/// style, letting the update record one style lookup per run +/// rather than per cell. +/// +/// - Dirty scans: OR-reducing groups of rows against the `dirty` +/// field mask finds whether any row in the group needs a rebuild +/// without touching each row's flag individually. +/// +/// T is the packed struct type, fields are the fields covered by the +/// mask, and group_len is the number of values processed at once by +/// the group (vectorized) operations. Callers typically scan a slice +/// with the group operations and fall back to the scalar variants for +/// the remainder and for pinpointing values within a matched group. +pub fn Mask( + comptime T: type, + comptime fields: []const []const u8, + comptime group_len_param: comptime_int, +) type { + return struct { + const Backing = @typeInfo(T).@"struct".backing_integer.?; + const mask: Backing = fieldMask(T, fields); + + /// The number of values processed at once by group operations. + pub const group_len = group_len_param; + + /// A group of raw values for the vectorized operations. + const Group = @Vector(group_len, Backing); + + /// Load a group of values from the slice starting at index i. + /// Asserts that at least group_len values are available. + inline fn load(values: []const T, i: usize) Group { + return @bitCast(values[i..][0..group_len].*); + } + + /// Returns the raw backing bits of a single value. + pub inline fn bits(v: T) Backing { + return @bitCast(v); + } + + /// Returns the masked bits of a single value: the bits of the + /// masked fields with all other fields zeroed. Use this to + /// build the expected value for the eql functions. + pub inline fn pattern(v: T) Backing { + return bits(v) & mask; + } + + /// Returns the backing bits of a single value with the masked + /// fields zeroed: the complement of `pattern`. Use this to + /// compare values while ignoring the masked fields. + pub inline fn strip(v: T) Backing { + return bits(v) & ~mask; + } + + /// Returns true if every value in the group of group_len + /// values starting at index i matches, where a value matches + /// when none of the masked fields have any bits set: false + /// for bools, zero for ints, the zero tag for enums, and so + /// on. Asserts that at least group_len values are available. + pub inline fn match(values: []const T, i: usize) bool { + return @reduce(.Or, load(values, i)) & mask == 0; + } + + /// Scalar variant of `match` for a single value. + pub inline fn matchScalar(v: T) bool { + return bits(v) & mask == 0; + } + + /// Returns true if the masked fields of every value in the + /// group of group_len values starting at index i equal the + /// expected pattern (see `pattern`). + /// + /// This is a masked compare: fields outside the mask may vary + /// freely. Use this to detect runs of values that share the + /// masked field contents while other fields differ, e.g. a run + /// of cells with the same style ID but different codepoints. + /// If the result you derive from a run depends on fields + /// outside the mask, use `eqlExact` instead. + pub inline fn eql( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const masked = load(values, i) & @as(Group, @splat(mask)); + return @reduce(.And, masked == @as(Group, @splat(expected))); + } + + /// Scalar variant of `eql` for a single value. + pub inline fn eqlScalar(v: T, expected: Backing) bool { + return pattern(v) == expected; + } + + /// Like `eql` but returns the number of leading values whose + /// masked fields equal the expected pattern, i.e. group_len if + /// the entire group matches. This is useful for early-exit run + /// scans that need to pinpoint exactly where a run ends rather + /// than only whether the whole group matches. + pub inline fn eqlPrefix( + values: []const T, + i: usize, + expected: Backing, + ) usize { + const masked = load(values, i) & @as(Group, @splat(mask)); + const ok = masked == @as(Group, @splat(expected)); + + // Test the whole group before extracting the prefix + // count: turning a vector compare into a scalar bitmask + // is expensive on some targets (e.g. NEON has no movemask + // instruction) and run scans overwhelmingly see fully + // matching groups, so we only pay for the extraction on + // the final group of a run. + if (@reduce(.And, ok)) { + @branchHint(.likely); + return group_len; + } + + const ok_bits: std.meta.Int( + .unsigned, + group_len, + ) = @bitCast(ok); + return @ctz(~ok_bits); + } + + /// Returns true if every value in the group of group_len + /// values starting at index i is bit-identical to the expected + /// value. Note: this compares entire values; it is NOT + /// affected by the field mask. + /// + /// This exists alongside `eql` for run detection where the + /// derived result depends on fields outside the mask, so a + /// masked compare would incorrectly extend the run. For + /// example, the background color of a bg-color cell lives in + /// the content field: two such cells only share a background + /// if their content bits are identical, not merely their + /// content tag. Values in such runs are typically produced by + /// bulk fills (e.g. erase with a pending background) and are + /// bit-identical in practice, so exact equality is both + /// correct and cheap. + pub inline fn eqlExact( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const group = load(values, i); + return @reduce(.And, group == @as(Group, @splat(expected))); + } + }; +} + +test "Mask" { + const M = Mask(Cell, &.{ "content_tag", "style_id" }, 4); + + const plain: Cell = .init('A'); + var styled: Cell = .init('B'); + styled.style_id = 5; + var styled2: Cell = .init('C'); + styled2.style_id = 5; + var other: Cell = .init('D'); + other.style_id = 6; + + // match: plain cells only + { + var cells: [4]Cell = .{ plain, plain, plain, plain }; + try testing.expect(M.match(&cells, 0)); + try testing.expect(M.matchScalar(plain)); + + cells[2] = styled; + try testing.expect(!M.match(&cells, 0)); + try testing.expect(!M.matchScalar(styled)); + } + + // eql: runs of matching masked fields, other fields may vary + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ styled, styled2, styled, styled2 }; + try testing.expect(M.eql(&cells, 0, expected)); + try testing.expect(M.eqlScalar(styled2, expected)); + + cells[1] = other; + try testing.expect(!M.eql(&cells, 0, expected)); + try testing.expect(!M.eqlScalar(other, expected)); + } + + // eqlPrefix: count of leading values matching the pattern + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ styled, styled2, other, styled }; + try testing.expectEqual(2, M.eqlPrefix(&cells, 0, expected)); + + cells[2] = styled; + try testing.expectEqual(4, M.eqlPrefix(&cells, 0, expected)); + } + + // eqlExact: bit-identical values only + { + const expected = M.bits(styled); + var cells: [4]Cell = .{ styled, styled, styled, styled }; + try testing.expect(M.eqlExact(&cells, 0, expected)); + + // Same masked fields but different codepoint is not exact. + cells[3] = styled2; + try testing.expect(!M.eqlExact(&cells, 0, expected)); + } + + // strip: compare values while ignoring the masked fields + { + var styled_other: Cell = .init('B'); + styled_other.style_id = 6; + try testing.expectEqual(M.strip(styled), M.strip(styled_other)); + try testing.expect(M.strip(styled) != M.strip(styled2)); + } +} + // Uncomment this when you want to do some math. // test "Page size calculator" { // const total_size = alignForward( @@ -3916,3 +4215,20 @@ test "Page exactRowCapacity hyperlink map capacity for many cells" { try testing.expect(cloned_cell.hyperlink); } } + +test "Page layout avoids double rounding hyperlink map capacity" { + const hyperlink_count = 3; + const layout = Page.layout(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = hyperlink_count * @sizeOf(hyperlink.Set.Item), + }); + + // Three set entries request 48 usable map entries. Scaling that for the + // 80% load factor needs 60 raw slots, which rounds once to 64. Rounding + // the request before applying the load factor would allocate 128 slots. + try std.testing.expectEqual( + @as(u32, 64), + layout.hyperlink_map_layout.capacity, + ); +} diff --git a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig index 8040039a..dd76079f 100644 --- a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig +++ b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig @@ -497,6 +497,15 @@ pub fn RefCountedSet( return self.lookupContext(base, value, self.context); } pub fn lookupContext(self: *const Self, base: anytype, value: T, ctx: Context) ?Id { + // A zero-capacity set (a valid special case of Layout.init) + // contains nothing and has a zero-size table, so we can't + // probe it: table[0] would read whatever memory follows the + // set in the backing buffer. + if (self.layout.table_cap == 0) { + @branchHint(.cold); + return null; + } + const table = self.table.ptr(base); const items = self.items.ptr(base); diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig index 98e14224..04f0c967 100644 --- a/vendor/libghostty-vt/src/terminal/render.zig +++ b/vendor/libghostty-vt/src/terminal/render.zig @@ -41,6 +41,29 @@ const Terminal = @import("Terminal.zig"); /// defer state.deinit(alloc); /// state.update(alloc, &terminal); /// +/// ## Two-Phase Updates +/// +/// For callers that synchronize terminal access (e.g. a renderer thread +/// sharing a lock with an IO thread), the update can be split into two +/// phases to minimize the time the terminal must be held exclusively: +/// `beginUpdate` requires terminal access, while `endUpdate` completes +/// any deferred work using only memory owned by the render state. +/// +/// { +/// mutex.lock(); +/// defer mutex.unlock(); +/// try state.beginUpdate(alloc, &terminal); +/// } +/// +/// // The IO thread is free to modify the terminal while we +/// // complete the update. +/// state.endUpdate(); +/// +/// The render state must be treated as incomplete between the two calls. +/// `update` is a convenience that performs both phases in one call. +/// +/// ## Memory +/// /// Note: the render state retains as much memory as possible between updates /// to prevent future allocations. If a very large frame is rendered once, /// the render state will retain that much memory until deinit. To avoid @@ -91,6 +114,16 @@ pub const RenderState = struct { /// if possible. selection_cache: ?SelectionCache = null, + /// The pending style runs requiring an endUpdate call, in the + /// order they were recorded. If multiple begins happen without an + /// endUpdate call, runs accumulate; rows rebuilt more than once + /// may then have superseded (stale) runs in this list, which is + /// harmless: newer runs are appended later so they win, and cells + /// not covered by newer runs have a default style ID in their raw + /// data so their style is undefined by contract anyway. See + /// beginUpdate. + pending_styles: std.ArrayList(StyleRun) = .empty, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -176,10 +209,16 @@ pub const RenderState = struct { /// change often. arena: ArenaAllocator.State, - /// The page pin. This is not safe to read unless you can guarantee - /// the terminal state hasn't changed since the last `update` call. + /// The page pin. Its copied values may be compared, but its node must + /// not be dereferenced unless the terminal state is protected from + /// changes since the last `update` call. pin: PageList.Pin, + /// The page node generation captured alongside `pin`. This lets + /// consumers validate the pin without dereferencing its node after + /// the terminal lock has been released. + serial: u64, + /// Raw row data. raw: page.Row, @@ -244,6 +283,23 @@ pub const RenderState = struct { br_pin: PageList.Pin, }; + /// A run of cells within one row sharing one style, pending + /// denormalization into the per-cell data. This is populated by + /// `beginUpdate` and consumed by `endUpdate`. This exists so that + /// the (potentially large) denormalization of styles into cells + /// can happen outside of any terminal locks. See `beginUpdate`. + pub const StyleRun = struct { + /// The viewport row. + y: size.CellCountInt, + + /// Start (inclusive) and end (exclusive) x coordinates. + start: size.CellCountInt, + end: size.CellCountInt, + + /// The style for this cell range. + style: Style, + }; + pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), @@ -254,10 +310,17 @@ pub const RenderState = struct { cells.deinit(alloc); } self.row_data.deinit(alloc); + self.pending_styles.deinit(alloc); } /// Update the render state to the latest terminal state. /// + /// This is a convenience function that performs a full update in + /// one call, equivalent to `beginUpdate` immediately followed by + /// `endUpdate`. Callers that hold a lock over the terminal state + /// should prefer calling the two phases directly so that the lock + /// is only held for `beginUpdate`. + /// /// This will reset the terminal dirty state since it is consumed /// by this render state update. pub fn update( @@ -265,6 +328,36 @@ pub const RenderState = struct { alloc: Allocator, t: *Terminal, ) Allocator.Error!void { + try self.beginUpdate(alloc, t); + self.endUpdate(); + } + + /// Begin an update of the render state to the latest terminal + /// state. Every begin must be completed with an `endUpdate` call + /// before the render state is read. + /// + /// This two-phase structure exists for callers that lock the + /// terminal state: only this function requires terminal access, so + /// a caller can hold its lock for this call only and then call + /// `endUpdate` after releasing it. `endUpdate` exclusively reads + /// and writes memory owned by the render state. + /// + /// Work that doesn't require terminal access may be deferred to + /// `endUpdate` to keep this call (and therefore lock hold time) as + /// short as possible. At the time of writing, the deferred work is + /// the per-cell style denormalization, so between this call and + /// `endUpdate` the per-cell `style` data of any updated rows is + /// stale and must not be read. More work may be deferred in the + /// future; callers should treat the render state as incomplete + /// until `endUpdate` is called. + /// + /// This will reset the terminal dirty state since it is consumed + /// by this render state update. + pub fn beginUpdate( + self: *RenderState, + alloc: Allocator, + t: *Terminal, + ) Allocator.Error!void { const s: *Screen = t.screens.active; const viewport_pin = s.pages.getTopLeft(.viewport); const redraw = redraw: { @@ -322,7 +415,14 @@ pub const RenderState = struct { // Colors. self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; + + // The palette is a relatively large copy (768 bytes at the time + // of writing) so we only copy it when it could have changed. All + // palette modifications set a terminal-level dirty flag (see + // Terminal.Dirty.palette), and any terminal-level dirty flag + // forces a redraw, so checking redraw is sufficient. + if (redraw) self.colors.palette = t.colors.palette.current; + bg_fg: { // Background/foreground can be unset initially which would // depend on "default" background/foreground. The expected use @@ -358,6 +458,7 @@ pub const RenderState = struct { row_data.set(y, .{ .arena = .{}, .pin = undefined, + .serial = undefined, .raw = undefined, .cells = .empty, .dirty = true, @@ -383,33 +484,62 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); - // Track the last page that we know was dirty. This lets us - // more quickly do the full-page dirty check. - var last_dirty_page: ?*page.Page = null; - - // Go through and setup our rows. - var row_it = s.pages.rowIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - var y: size.CellCountInt = 0; + // If we're redrawing then every row will be rebuilt, superseding + // any pending style runs from prior updates. Clearing also + // guarantees pending runs always match the current dimensions + // (dimension changes force a redraw). + if (redraw) self.pending_styles.clearRetainingCapacity(); + + // Go through and setup our rows. We iterate page chunks rather + // than individual rows so that per-page work (dirty flags, cursor + // detection, memory pointers) is hoisted out of the row loop. This + // makes the common case of a clean (or mostly clean) frame very + // cheap: a contiguous scan of row dirty flags. + const builder: RowBuilder = .{ + .alloc = alloc, + .cols = self.cols, + .arenas = row_arenas, + .raws = row_rows, + .cells = row_cells, + .sels = row_sels, + .highlights = row_highlights, + .dirties = row_dirties, + .pending_styles = &self.pending_styles, + }; + var y: usize = 0; var any_dirty: bool = false; - while (row_it.next()) |row_pin| : (y = y + 1) { + var page_it = viewport_pin.pageIterator(.right_down, null); + while (y < self.rows) { + const chunk = page_it.next() orelse break; + const node = chunk.node; + const node_serial = node.serial; + const p: *page.Page = node.page(); + + // The number of rows we consume from this chunk. The chunk + // may extend beyond the viewport (the viewport is always + // exactly `rows` tall) so we clamp. + const take: usize = @min( + @as(usize, chunk.end - chunk.start), + self.rows - y, + ); + // Find our cursor if we haven't found it yet. We do this even - // if the row is not dirty because the cursor is unrelated. + // if rows are not dirty because the cursor is unrelated. We + // can check the chunk bounds once rather than every row. if (self.cursor.viewport == null and - row_pin.node == s.cursor.page_pin.node and - row_pin.y == s.cursor.page_pin.y) - { + node == s.cursor.page_pin.node) + cursor: { + const cy = s.cursor.page_pin.y; + if (cy < chunk.start or cy >= chunk.start + take) break :cursor; self.cursor.viewport = .{ - .y = y, + .y = @intCast(y + (cy - chunk.start)), .x = s.cursor.x, // Future: we should use our own state here to look this @@ -421,135 +551,82 @@ pub const RenderState = struct { }; } - // Store our pin. We have to store these even if we're not dirty - // because dirty is only a renderer optimization. It doesn't - // apply to memory movement. This will let us remap any cell - // pins back to an exact entry in our RenderState. - row_pins[y] = row_pin; - - // Get all our cells in the page. - const p: *page.Page = &row_pin.node.data; - const page_rac = row_pin.rowAndCell(); - - dirty: { - // If we're redrawing then we're definitely dirty. - if (redraw) break :dirty; - - // If our page is the same as last time then its dirty. - if (p == last_dirty_page) break :dirty; - if (p.dirty) { - // If this page is dirty then clear the dirty flag - // of the last page and then store this one. This benchmarks - // faster than iterating pages again later. - if (last_dirty_page) |last_p| last_p.dirty = false; - last_dirty_page = p; - break :dirty; + // The page-level dirty flag applies to every row in the chunk. + // We consume (clear) it now; each node appears at most once in + // this iteration and we're the only consumer of dirty state. + const page_dirty = p.dirty; + if (page_dirty) p.dirty = false; + + // Get our contiguous rows for this chunk. + const page_rows: []page.Row = p.rows.ptr(p.memory)[chunk.start..][0..take]; + assert(p.size.cols == self.cols); + + // Store our pins and their node generations. We have to store + // these even for rows that aren't dirty because dirty is only a + // renderer optimization; it doesn't apply to memory movement. + // This lets us remap any cell pins back to an exact entry in our + // RenderState and validate them later without dereferencing a + // potentially stale node. + // + // We can skip the writes when the pins and serials are unchanged: + // if we're not redrawing, every value was stored by a prior update + // (row count changes force a redraw). Within a single update a + // node appears at most once and its stored pins have consecutive + // y values, so if the first and last entries of this chunk's range + // already match then every entry in between matches too. + if (redraw or + row_pins[y].node != node or + row_pins[y].y != chunk.start or + row_serials[y] != node_serial or + row_pins[y + take - 1].node != node or + row_pins[y + take - 1].y != chunk.start + take - 1 or + row_serials[y + take - 1] != node_serial) + { + for ( + row_pins[y..][0..take], + row_serials[y..][0..take], + chunk.start.., + ) |*pin, *serial, py| { + pin.* = .{ .node = node, .y = @intCast(py) }; + serial.* = node_serial; } - - // If our row is dirty then we're dirty. - if (page_rac.row.dirty) break :dirty; - - // Not dirty! - continue; } - // Set that at least one row was dirty. - any_dirty = true; - - // Clear our row dirty, we'll clear our page dirty later. - // We can't clear it now because we have more rows to go through. - page_rac.row.dirty = false; - - // Promote our arena. State is copied by value so we need to - // restore it on all exit paths so we don't leak memory. - var arena = row_arenas[y].promote(alloc); - defer row_arenas[y] = arena.state; - - // Reset our cells if we're rebuilding this row. - if (row_cells[y].len > 0) { - _ = arena.reset(.retain_capacity); - row_cells[y].clearRetainingCapacity(); - row_sels[y] = null; - row_highlights[y] = .empty; - } - row_dirties[y] = true; - - // Get all our cells in the page. - const page_cells: []const page.Cell = p.getCells(page_rac.row); - assert(page_cells.len == self.cols); - - // Copy our raw row data - row_rows[y] = page_rac.row.*; - - // Note: our cells MultiArrayList uses our general allocator. - // We do this on purpose because as rows become dirty, we do - // not want to reallocate space for cells (which are large). This - // was a source of huge slowdown. - // - // Our per-row arena is only used for temporary allocations - // pertaining to cells directly (e.g. graphemes, hyperlinks). - const cells: *std.MultiArrayList(Cell) = &row_cells[y]; - try cells.resize(alloc, self.cols); - - // We always copy our raw cell data. In the case we have no - // managed memory, we can skip setting any other fields. - // - // This is an important optimization. For plain-text screens - // this ends up being something around 300% faster based on - // the `screen-clone` benchmark. - const cells_slice = cells.slice(); - fastmem.copy( - page.Cell, - cells_slice.items(.raw), - page_cells, - ); - if (!page_rac.row.managedMemory()) continue; - - const arena_alloc = arena.allocator(); - const cells_grapheme = cells_slice.items(.grapheme); - const cells_style = cells_slice.items(.style); - for (page_cells, 0..) |*page_cell, x| { - // Append assuming its a single-codepoint, styled cell - // (most common by far). - if (page_cell.style_id > 0) cells_style[x] = p.styles.get( - p.memory, - page_cell.style_id, - ).*; - - // Switch on our content tag to handle less likely cases. - switch (page_cell.content_tag) { - .codepoint => { + if (!redraw and !page_dirty) { + // Only dirty rows (usually none) need a rebuild. Scan the + // dirty flags a group at a time; the dirty bit is directly + // testable on the packed row representation. + var i: usize = 0; + while (take - i >= RowDirtyMask.group_len) : (i += RowDirtyMask.group_len) { + if (RowDirtyMask.match(page_rows, i)) { @branchHint(.likely); - // Primary codepoint goes into `raw` field. - }, - - // If we have a multi-codepoint grapheme, look it up and - // set our content type. - .codepoint_grapheme => { - @branchHint(.unlikely); - cells_grapheme[x] = try arena_alloc.dupe( - u21, - p.lookupGrapheme(page_cell) orelse &.{}, - ); - }, - - .bg_color_rgb => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ .rgb = .{ - .r = page_cell.content.color_rgb.r, - .g = page_cell.content.color_rgb.g, - .b = page_cell.content.color_rgb.b, - } } }; - }, + continue; + } - .bg_color_palette => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ - .palette = page_cell.content.color_palette, - } }; - }, + for (page_rows[i..][0..RowDirtyMask.group_len], i..) |*page_row, j| { + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + j); + } + } + while (i < take) : (i += 1) { + const page_row = &page_rows[i]; + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + i); + } + } else { + // Rebuild every row in the chunk. + any_dirty = true; + for (page_rows, 0..) |*page_row, i| { + page_row.dirty = false; + try builder.row(p, page_row, y + i); } } + + y += take; } assert(y == self.rows); @@ -640,14 +717,40 @@ pub const RenderState = struct { self.dirty = .partial; } - // Finalize our final dirty page - if (last_dirty_page) |last_p| last_p.dirty = false; - // Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; } + /// Complete a prior `beginUpdate` call by performing any deferred + /// work. At the time of writing, this denormalizes the pending + /// style runs into the per-cell style data. + /// + /// This only reads and writes memory owned by the render state, so + /// it is safe to call while the terminal is being modified (no + /// terminal lock is required). + pub fn endUpdate(self: *RenderState) void { + // Common case: no styled rows were rebuilt. + if (self.pending_styles.items.len == 0) return; + + const row_data = self.row_data.slice(); + const row_cells = row_data.items(.cells); + for (self.pending_styles.items) |run| { + // Defensive: the row data may have changed shape if the + // caller violated ordering (e.g. an error path skipped an + // endUpdate between updates). Any update that changes + // dimensions clears the pending list (redraw), so this + // should never actually trigger, but the cost is trivial. + if (run.y >= row_cells.len) continue; + const styles = row_cells[run.y].slice().items(.style); + const end = @min(run.end, styles.len); + const start = @min(run.start, end); + + @memset(styles[start..end], run.style); + } + self.pending_styles.clearRetainingCapacity(); + } + /// Update the highlights in the render state from the given flattened /// highlights. Because this uses flattened highlights, it does not require /// reading from the terminal state so it should be done outside of @@ -678,22 +781,28 @@ pub const RenderState = struct { const row_arenas = row_data.items(.arena); const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, + row_serials, row_highlights_slice, row_dirties, - ) |*row_arena, row_pin, *row_highlights, *dirty| { + ) |*row_arena, row_pin, row_serial, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); + const serials = chunks_slice.items(.serial); const starts = chunks_slice.items(.start); const ends = chunks_slice.items(.end); for (0.., nodes) |i, node| { - // If this node doesn't match or we're not within - // the row range, skip it. + // If this node generation doesn't match or we're not + // within the row range, skip it. Both serials are copied + // values, so this never dereferences a node outside the + // terminal lock. if (node != row_pin.node or + serials[i] != row_serial or row_pin.y < starts[i] or row_pin.y >= ends[i]) continue; @@ -825,7 +934,7 @@ pub const RenderState = struct { // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; - const link_page: *page.Page = &link_pin.node.data; + const link_page: *page.Page = link_pin.node.page(); const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, @@ -854,7 +963,7 @@ pub const RenderState = struct { for (0.., cells.items(.raw)) |x, cell| { if (!cell.hyperlink) continue; - const other_page: *page.Page = &pin.node.data; + const other_page: *page.Page = pin.node.page(); const other = link: { const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; @@ -879,6 +988,231 @@ pub const RenderState = struct { } }; +/// The number of rows/cells we scan as a single group when looking for +/// dirty rows or special cells. Rows and cells are small packed structs +/// so a group is scanned with a handful of vector operations. +const scan_group_len = 8; + +/// Group scan helper for the row dirty flag. A row that matches has +/// its dirty flag unset. +const RowDirtyMask = page.Mask( + page.Row, + &.{"dirty"}, + scan_group_len, +); + +/// Group scan helper for the cell fields that require managed memory +/// handling. A cell that matches is a plain (possibly zero) codepoint +/// with a default style, requiring no work beyond the raw copy. See +/// RowBuilder.row. +const CellSpecialMask = page.Mask(page.Cell, &.{ + "content_tag", + "style_id", +}, scan_group_len); + +/// Internal helper for RenderState.update that rebuilds a single row of +/// the render state from the current page contents. +const RowBuilder = struct { + alloc: Allocator, + cols: usize, + arenas: []ArenaAllocator.State, + raws: []page.Row, + cells: []std.MultiArrayList(RenderState.Cell), + sels: []?[2]size.CellCountInt, + highlights: []std.ArrayList(RenderState.Highlight), + dirties: []bool, + pending_styles: *std.ArrayList(RenderState.StyleRun), + + fn row( + b: *const RowBuilder, + p: *page.Page, + page_row: *const page.Row, + vy: usize, + ) Allocator.Error!void { + // Promote our arena. State is copied by value so we need to + // restore it on all exit paths so we don't leak memory. + var arena = b.arenas[vy].promote(b.alloc); + defer b.arenas[vy] = arena.state; + + // Reset our per-row state if we're rebuilding this row. A + // non-zero cell length means the row was populated by a prior + // update. + if (b.cells[vy].len > 0) { + _ = arena.reset(.retain_capacity); + b.sels[vy] = null; + b.highlights[vy] = .empty; + } + b.dirties[vy] = true; + + // Get all our cells in the page. + const page_cells: []const page.Cell = page_row.cells.ptr(p.memory)[0..b.cols]; + + // Copy our raw row data + b.raws[vy] = page_row.*; + + // Note: our cells MultiArrayList uses our general allocator. + // We do this on purpose because as rows become dirty, we do + // not want to reallocate space for cells (which are large). This + // was a source of huge slowdown. + // + // Our per-row arena is only used for temporary allocations + // pertaining to cells directly (e.g. graphemes, hyperlinks). + const cells: *std.MultiArrayList(RenderState.Cell) = &b.cells[vy]; + if (cells.len != b.cols) try cells.resize(b.alloc, b.cols); + + // We always copy our raw cell data. In the case we have no + // managed memory, we can skip setting any other fields. + // + // This is an important optimization. For plain-text screens + // this ends up being something around 300% faster based on + // the `screen-clone` benchmark. + const cells_slice = cells.slice(); + fastmem.copy( + page.Cell, + cells_slice.items(.raw), + page_cells, + ); + if (!page_row.managedMemory()) return; + + const arena_alloc = arena.allocator(); + const cells_grapheme = cells_slice.items(.grapheme); + const n = page_cells.len; + var x: usize = 0; + scan: while (x < n) { + // Skip runs of plain cells a group at a time. Cells that + // need managed handling are often rare even within rows that + // have managed memory (e.g. a row is "styled" if a single + // cell has a style) so groups are skipped with a few vector + // operations. + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.match(page_cells, x)) break; + x += CellSpecialMask.group_len; + } + + // Scalar scan to the next special cell. + while (true) { + if (x >= n) break :scan; + if (!CellSpecialMask.matchScalar(page_cells[x])) break; + x += 1; + } + + const page_cell = &page_cells[x]; + + switch (page_cell.content_tag) { + // Single-codepoint styled cells are by far the most + // common special cells, and they usually come in long + // runs sharing one style ID (e.g. a fully styled row + // usually uses a single style). Find the run and record + // it: this does one style lookup per run and defers the + // (large) per-cell fill to endUpdate, outside of any + // terminal locks. + .codepoint => { + @branchHint(.likely); + const sid = page_cell.style_id; + assert(sid > 0); // special + codepoint implies styled + const style_val: Style = p.styles.get(p.memory, sid).*; + + // A cell continues the run if its masked special + // bits are exactly the style ID of the run (in + // particular the content tag must be a plain + // codepoint). We can check groups of cells at a + // time this way. + const pattern = CellSpecialMask.pattern(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eql( + page_cells, + x, + pattern, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (!CellSpecialMask.eqlScalar( + page_cells[x], + pattern, + )) break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + + // If we have a multi-codepoint grapheme, look it up and + // set our content type. Note grapheme cells may also + // be styled. The style must be recorded as a run (rather + // than written directly) so that it is ordered correctly + // relative to possibly-stale runs from prior updates. + .codepoint_grapheme => { + if (page_cell.style_id > 0) { + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(x), + .end = @intCast(x + 1), + .style = p.styles.get( + p.memory, + page_cell.style_id, + ).*, + }); + } + cells_grapheme[x] = try arena_alloc.dupe( + u21, + p.lookupGrapheme(page_cell) orelse &.{}, + ); + x += 1; + }, + + // Background-color-only cells. The style is derived + // entirely from the cell contents. Consecutive cleared + // cells with the same background are bit-identical, so + // we run-detect on full equality (e.g. a line cleared + // with a background color pending is one run). + .bg_color_rgb, .bg_color_palette => { + const style_val: Style = switch (page_cell.content_tag) { + .bg_color_rgb => .{ .bg_color = .{ .rgb = .{ + .r = page_cell.content.color_rgb.r, + .g = page_cell.content.color_rgb.g, + .b = page_cell.content.color_rgb.b, + } } }, + .bg_color_palette => .{ .bg_color = .{ + .palette = page_cell.content.color_palette, + } }, + else => unreachable, + }; + + const first_bits = CellSpecialMask.bits(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eqlExact( + page_cells, + x, + first_bits, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (CellSpecialMask.bits(page_cells[x]) != first_bits) + break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + } + } + } +}; + test "styled" { const testing = std.testing; const alloc = testing.allocator; @@ -979,6 +1313,331 @@ test "styled text" { try testing.expectEqual(0, cells[0].get(3).raw.codepoint()); } +/// Verifies that an incrementally updated render state has identical +/// contents to a from-scratch rebuild. This is the load-bearing check +/// for our dirty tracking: if any terminal operation changes row +/// contents without setting a dirty signal that `update` honors +/// (terminal dirty, screen dirty, page dirty, row dirty, viewport pin, +/// or dimensions), the incremental state will contain stale rows and +/// this comparison will fail. +fn testCompareStates( + incremental: *const RenderState, + fresh: *const RenderState, +) !void { + const testing = std.testing; + + // Row metadata that is allowed to be stale in an incremental + // update. Dirty tracking only guarantees that VISUAL changes are + // flagged (see page.Row.dirty); these fields are non-visual + // metadata that the terminal may change without dirtying the row + // (e.g. Screen.cursorResetWrap clears wrap flags without a dirty + // mark). This staleness predates the chunked update + // implementation; it is present in the row-iterator implementation + // as well. + const StaleOkMask = page.Mask(page.Row, &.{ + "wrap", + "wrap_continuation", + "semantic_prompt", + "dirty", + }, 1); + + try testing.expectEqual(fresh.rows, incremental.rows); + try testing.expectEqual(fresh.cols, incremental.cols); + try testing.expectEqual(fresh.cursor.active, incremental.cursor.active); + try testing.expectEqual(fresh.cursor.viewport, incremental.cursor.viewport); + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(fresh.cursor.cell)), + @as(page.Cell.Backing, @bitCast(incremental.cursor.cell)), + ); + + const inc_data = incremental.row_data.slice(); + const new_data = fresh.row_data.slice(); + try testing.expectEqual(new_data.len, inc_data.len); + for (0..new_data.len) |y| { + errdefer std.log.warn("mismatch on row y={}", .{y}); + + // Pins must match exactly. + const inc_pin = inc_data.items(.pin)[y]; + const new_pin = new_data.items(.pin)[y]; + try testing.expectEqual(new_pin.node, inc_pin.node); + try testing.expectEqual(new_pin.y, inc_pin.y); + + // Raw row data must match, except for non-visual metadata + // fields which may legitimately be stale (see StaleOkMask). + const inc_row = inc_data.items(.raw)[y]; + const new_row = new_data.items(.raw)[y]; + try testing.expectEqual( + StaleOkMask.strip(new_row), + StaleOkMask.strip(inc_row), + ); + + const inc_cells = inc_data.items(.cells)[y].slice(); + const new_cells = new_data.items(.cells)[y].slice(); + try testing.expectEqual(new_cells.len, inc_cells.len); + const managed = new_row.managedMemory(); + for (0..new_cells.len) |x| { + errdefer std.log.warn("mismatch on cell x={}", .{x}); + + // Raw cell contents must match. + const inc_cell = inc_cells.items(.raw)[x]; + const new_cell = new_cells.items(.raw)[x]; + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(new_cell)), + @as(page.Cell.Backing, @bitCast(inc_cell)), + ); + + // The style is only defined if the cell is styled or is + // a bg-color cell within a row that has managed memory. + if (new_cell.style_id != 0 or + (managed and switch (new_cell.content_tag) { + .bg_color_rgb, .bg_color_palette => true, + else => false, + })) + { + try testing.expect(std.meta.eql( + new_cells.items(.style)[x], + inc_cells.items(.style)[x], + )); + } + + // Graphemes are only defined for grapheme cells. + if (new_cell.content_tag == .codepoint_grapheme) { + try testing.expectEqualSlices( + u21, + new_cells.items(.grapheme)[x], + inc_cells.items(.grapheme)[x], + ); + } + } + } +} + +test "incremental updates match full rebuild" { + const testing = std.testing; + const alloc = testing.allocator; + + // Deterministic so failures are reproducible. + var prng = std.Random.DefaultPrng.init(0xB0BA_CAFE); + const rand = prng.random(); + + var t = try Terminal.init(alloc, .{ + .cols = 20, + .rows = 8, + .max_scrollback = 500, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var inc: RenderState = .empty; + defer inc.deinit(alloc); + + var buf: [64]u8 = undefined; + for (0..300) |_| { + // Perform a random batch of operations between updates. + for (0..rand.intRangeAtMost(usize, 1, 6)) |_| { + switch (rand.intRangeAtMost(u8, 0, 18)) { + // Plain text (possibly wrapping and scrolling). + 0, 1, 2 => for (0..rand.intRangeAtMost(usize, 1, 30)) |_| { + s.nextSlice(&.{rand.intRangeAtMost(u8, 'A', 'Z')}); + }, + + // Newlines to build scrollback and trigger pruning. + 3, 4 => for (0..rand.intRangeAtMost(usize, 1, 10)) |_| { + s.nextSlice("x\r\n"); + }, + + // Cursor movement. + 5 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{};{}H", .{ + rand.intRangeAtMost(u16, 1, 8), + rand.intRangeAtMost(u16, 1, 20), + })), + + // Styling: bold, truecolor bg, palette fg, reset. + 6 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => "\x1b[1m", + 1 => "\x1b[48;2;30;60;90m", + 2 => "\x1b[38;5;120m", + else => "\x1b[0m", + }), + + // Erase ops (EL, ED variants including scrollback). + 7 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 4)) { + 0 => "\x1b[K", + 1 => "\x1b[1K", + 2 => "\x1b[J", + 3 => "\x1b[2J", + else => "\x1b[3J", + }), + + // Insert/delete lines (row rotations within regions). + 8 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}L", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 9 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}M", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Scroll up/down (page-dirty row rotations). + 10 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}S", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 11 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}T", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Set/reset scroll regions to exercise bounded scrolls. + 12 => { + const top = rand.intRangeAtMost(u16, 1, 4); + const bot = rand.intRangeAtMost(u16, top + 1, 8); + s.nextSlice(try std.fmt.bufPrint( + &buf, + "\x1b[{};{}r", + .{ top, bot }, + )); + }, + + // Insert/delete/erase chars within a row. + 13 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}@", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + 14 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}P", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + + // Reverse index (scroll down at top). + 15 => s.nextSlice("\x1bM"), + + // Wide chars and multi-codepoint graphemes. + 16 => s.nextSlice("字👨👩👧"), + + // Alternate screen switching (screen key redraw path). + 17 => s.nextSlice(if (rand.boolean()) + "\x1b[?1049h" + else + "\x1b[?1049l"), + + // DECALN full-screen fill. + 18 => s.nextSlice("\x1b#8"), + + else => unreachable, + } + } + + // Occasionally scroll the viewport into scrollback and back. + switch (rand.intRangeAtMost(u8, 0, 9)) { + 0 => t.scrollViewport(.{ .delta = -3 }), + 1 => t.scrollViewport(.{ .delta = 2 }), + 2 => t.scrollViewport(.bottom), + 3 => t.scrollViewport(.top), + else => {}, + } + + // Update our incremental state first: it must consume the dirty + // state. The fresh state always fully rebuilds (its dimensions + // start empty so it always redraws) and so does not depend on + // any dirty flags. + try inc.update(alloc, &t); + + var fresh: RenderState = .empty; + defer fresh.deinit(alloc); + try fresh.update(alloc, &t); + + try testCompareStates(&inc, &fresh); + } +} + +test "begin and end update" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + s.nextSlice("\x1b[0;3mC"); // Italic + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.beginUpdate(alloc, &t); + + // We should have pending style runs on row 0: one for the bold + // run and one for the italic run. + { + const runs = state.pending_styles.items; + try testing.expectEqual(2, runs.len); + try testing.expectEqual(0, runs[0].y); + try testing.expectEqual(0, runs[0].start); + try testing.expectEqual(2, runs[0].end); + try testing.expect(runs[0].style.flags.bold); + try testing.expectEqual(0, runs[1].y); + try testing.expectEqual(2, runs[1].start); + try testing.expectEqual(3, runs[1].end); + try testing.expect(runs[1].style.flags.italic); + } + + // End our update. This should denormalize the runs into cells + // and clear the pending runs. + state.endUpdate(); + { + try testing.expectEqual(0, state.pending_styles.items.len); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + try testing.expect(cells[0].get(0).style.flags.bold); + try testing.expect(cells[0].get(1).style.flags.bold); + try testing.expect(cells[0].get(2).style.flags.italic); + } +} + +test "bg color cells" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Write a styled cell (so the row has managed memory) then erase + // the rest of the line with a palette background pending. The + // erase produces bg_color content cells rather than styled cells. + s.nextSlice("\x1b[1mA\x1b[48;5;1m\x1b[K"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + try testing.expect(cell.style.flags.bold); + } + for (1..10) |x| { + const cell = cells[0].get(x); + try testing.expectEqual( + page.Cell.ContentTag.bg_color_palette, + cell.raw.content_tag, + ); + try testing.expectEqual( + Style.Color{ .palette = 1 }, + cell.style.bg_color, + ); + } +} + test "grapheme" { const testing = std.testing; const alloc = testing.allocator; @@ -1343,7 +2002,7 @@ test "linkCells with scrollback spanning pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_cap = pages.pages.first.?.data.capacity.rows; + const first_page_cap = pages.pages.first.?.capacity().rows; // Fill first page for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); @@ -1405,6 +2064,73 @@ test "linkCells with invalid viewport point" { } } +test "flattened highlights require matching page serial" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + // Capture the live generation while terminal-owned state is in scope so + // we can also verify beginUpdate copies it into the render row. + const live_pin = t.screens.active.pages.getTopLeft(.viewport); + const live_serial = live_pin.node.serial; + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const pin: PageList.Pin = pin: { + const row_data = state.row_data.slice(); + @memset(row_data.items(.dirty), false); + state.dirty = .false; + break :pin row_data.items(.pin)[0]; + }; + const row_serial = state.row_data.items(.serial)[0]; + try testing.expectEqual(live_pin.node, pin.node); + try testing.expectEqual(live_serial, row_serial); + + // Use the exact node pointer and row captured by the render state, but a + // different generation. A reused node address must not make this stale + // flattened highlight match. + var hl: highlight.Flattened = .{ + .chunks = .empty, + .top_x = 2, + .bot_x = 4, + }; + defer hl.deinit(alloc); + try hl.chunks.append(alloc, .{ + .node = pin.node, + .serial = live_serial ^ 1, + .start = pin.y, + .end = pin.y + 1, + }); + + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + try testing.expectEqual(0, row_data.items(.highlights)[0].items.len); + try testing.expect(!row_data.items(.dirty)[0]); + try testing.expectEqual(.false, state.dirty); + } + + // The same chunk is accepted once its copied serial also matches. + hl.chunks.items(.serial)[0] = live_serial; + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights)[0].items; + try testing.expectEqual(1, row_highlights.len); + try testing.expectEqual(42, row_highlights[0].tag); + try testing.expectEqual([2]size.CellCountInt{ 2, 4 }, row_highlights[0].range); + try testing.expect(row_data.items(.dirty)[0]); + try testing.expectEqual(.partial, state.dirty); + } +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/search/Thread.zig b/vendor/libghostty-vt/src/terminal/search/Thread.zig index fa09af5f..d7f0d562 100644 --- a/vendor/libghostty-vt/src/terminal/search/Thread.zig +++ b/vendor/libghostty-vt/src/terminal/search/Thread.zig @@ -121,7 +121,11 @@ pub fn deinit(self: *Thread) void { // Nothing can possibly access the mailbox anymore, destroy it. self.mailbox.destroy(self.alloc); - if (self.search) |*s| s.deinit(); + if (self.search) |*s| { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.deinit(self.opts.terminal); + } } /// The main entrypoint for the thread. @@ -252,11 +256,15 @@ fn drainMailbox(self: *Thread) !void { fn select(self: *Thread, sel: ScreenSearch.Select) !void { const s = if (self.search) |*s| s else return; - const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; self.opts.mutex.lock(); defer self.opts.mutex.unlock(); + // A screen can be removed or replaced between refresh ticks. Reconcile + // while holding the terminal lock before touching any ScreenSearch pins. + s.feed(self.alloc, self.opts.terminal); + const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; + // Make the selection. Ignore the result because we don't // care if the selection didn't change. _ = try screen_search.select(sel); @@ -308,7 +316,11 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // If our search is unchanged, do nothing. if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; - s.deinit(); + { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.deinit(self.opts.terminal); + } self.search = null; // When the search changes then we need to emit that it stopped. @@ -497,6 +509,11 @@ const Search = struct { /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + /// ScreenSet generations captured when each searcher was initialized. + /// Allocators may reuse a destroyed Screen address, so pointer equality + /// alone cannot distinguish replacement screens from stale handles. + screen_generations: std.EnumMap(ScreenSet.Key, usize), + /// All state related to screen switches, collected so that when /// we switch screens it makes everything related stale, too. last_screen: ScreenState, @@ -537,16 +554,35 @@ const Search = struct { return .{ .viewport = vp, .screens = .init(.{}), + .screen_generations = .init(.{}), .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; } - pub fn deinit(self: *Search) void { + pub fn deinit(self: *Search, t: *Terminal) void { self.viewport.deinit(); var it = self.screens.iterator(); - while (it.next()) |entry| entry.value.deinit(); + while (it.next()) |entry| { + if (self.screenIsValid(&t.screens, entry.key, entry.value)) { + entry.value.deinit(); + } else { + entry.value.deinitScreenInvalid(); + } + } + } + + fn screenIsValid( + self: *const Search, + screens: *const ScreenSet, + key: ScreenSet.Key, + search: *const ScreenSearch, + ) bool { + const generation = self.screen_generations.get(key) orelse return false; + if (generation != screens.generation(key)) return false; + const actual = screens.get(key) orelse return false; + return actual == search.screen; } /// Returns true if all searches on all screens are complete. @@ -629,18 +665,16 @@ const Search = struct { // Remove screens we have that no longer exist or changed. var it = self.screens.iterator(); while (it.next()) |entry| { - const remove: bool = remove: { - // If the screen doesn't exist at all, remove it. - const actual = t.screens.all.get(entry.key) orelse break :remove true; - - // If the screen pointer changed, remove it, the screen - // was totally reinitialized. - break :remove actual != entry.value.screen; - }; + const remove = !self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ); if (remove) { - entry.value.deinit(); + entry.value.deinitScreenInvalid(); _ = self.screens.remove(entry.key); + _ = self.screen_generations.remove(entry.key); } } } @@ -649,7 +683,7 @@ const Search = struct { var it = t.screens.all.iterator(); while (it.next()) |entry| { if (self.screens.contains(entry.key)) continue; - self.screens.put(entry.key, ScreenSearch.init( + const screen_search = ScreenSearch.init( alloc, entry.value.*, self.viewport.needle(), @@ -664,7 +698,12 @@ const Search = struct { ); continue; }, - }); + }; + self.screens.put(entry.key, screen_search); + self.screen_generations.put( + entry.key, + t.screens.generation(entry.key), + ); } } @@ -903,3 +942,32 @@ test { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select after active screen removal" { + const alloc = testing.allocator; + var mutex: std.Thread.Mutex = .{}; + var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + + var search: Search = try .init(alloc, "needle"); + search.feed(alloc, &t); + try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key); + try testing.expect(search.screens.contains(.alternate)); + + var thread: Thread = undefined; + thread.search = search; + thread.opts = .{ + .mutex = &mutex, + .terminal = &t, + }; + defer if (thread.search) |*active| active.deinit(&t); + + _ = try t.switchScreen(.primary); + t.screens.remove(alloc, .alternate); + + try thread.select(.next); + try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key); + try testing.expect(!thread.search.?.screens.contains(.alternate)); +} diff --git a/vendor/libghostty-vt/src/terminal/search/active.zig b/vendor/libghostty-vt/src/terminal/search/active.zig index 692a10e1..b99b2357 100644 --- a/vendor/libghostty-vt/src/terminal/search/active.zig +++ b/vendor/libghostty-vt/src/terminal/search/active.zig @@ -56,6 +56,10 @@ pub const ActiveSearch = struct { // Clear our previous sliding window self.window.clearAndRetainCapacity(); + // An empty needle represents an inactive search and has no overlap + // or history to load. + if (self.window.needle.len == 0) return null; + // First up, add enough pages to cover the active area. var rem: usize = list.rows; var node_ = list.pages.last; @@ -68,26 +72,22 @@ pub const ActiveSearch = struct { // page that contains the active area. We go to the previous // page once more since its the first page of our required // overlap. - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { node_ = node.prev; break; } - rem -= node.data.size.rows; + rem -= node.rows(); } // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - const added = try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + if (appended.content_len >= self.window.needle.len - 1) break; } // Return the last node we added to our window. diff --git a/vendor/libghostty-vt/src/terminal/search/pagelist.zig b/vendor/libghostty-vt/src/terminal/search/pagelist.zig index f76ad4e4..98a24c18 100644 --- a/vendor/libghostty-vt/src/terminal/search/pagelist.zig +++ b/vendor/libghostty-vt/src/terminal/search/pagelist.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const testing = std.testing; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; @@ -56,8 +57,8 @@ pub const PageListSearch = struct { // be moved somewhere safe. const pin = try list.trackPin(.{ .node = start, - .y = start.data.size.rows - 1, - .x = start.data.size.cols - 1, + .y = start.rows() - 1, + .x = start.cols() - 1, }); errdefer list.untrackPin(pin); @@ -84,6 +85,13 @@ pub const PageListSearch = struct { self.list.untrackPin(self.pin); } + /// Release owned search buffers after the PageList itself has already + /// been destroyed. Its pin belonged to the PageList pool and was freed + /// with that list, so it must not be untracked here. + pub fn deinitListInvalid(self: *PageListSearch) void { + self.window.deinit(); + } + /// Return the next match in the loaded page nodes. If this returns /// null then the PageList search needs to be fed the next node(s). /// Call, `feed` to do this. @@ -121,10 +129,13 @@ pub const PageListSearch = struct { // get our desired amount of data. var node_: ?*PageList.List.Node = self.pin.node.prev; while (node_) |node| : (node_ = node.prev) { - rem -|= try self.window.append(node); + rem -|= (try self.window.append(node)).content_len; // Move our tracked pin to the new node. self.pin.node = node; + self.pin.y = node.rows() - 1; + self.pin.x = node.cols() - 1; + self.pin.garbage = false; if (rem == 0) break; } @@ -190,7 +201,7 @@ test "feed multiple pages with matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); @@ -234,7 +245,7 @@ test "feed multiple pages no matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Hello"); @@ -272,7 +283,7 @@ test "feed iteratively through multiple matches" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page with a match at the end for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -313,7 +324,7 @@ test "feed with match spanning page boundary" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -359,6 +370,76 @@ test "feed with match spanning page boundary" { try testing.expect(!try search.feed()); } +test "compressed history match spanning page boundary remains compressed" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + + const pages = &t.screens.active.pages; + const first = pages.pages.first.?; + const first_page_rows = first.capacity().rows; + + // Put half the needle on either side of a soft-wrapped page boundary. + for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n"); + for (0..pages.cols - 2) |_| stream.nextSlice("x"); + stream.nextSlice("Test"); + const second = first.next.?; + try testing.expectEqual(second, pages.pages.last.?); + + // Move both matching pages completely into history. The third page holds + // the active area, making the first two eligible for compression. + while (pages.pages.last.? == second) stream.nextSlice("\r\n"); + for (0..pages.rows) |_| stream.nextSlice("\r\n"); + try testing.expect(pages.getTopLeft(.active).node != second); + + _ = pages.compress(.full); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); + const compressed = pages.memoryStats(); + try testing.expect(compressed.compressed_pages >= 2); + + var search: PageListSearch = try .init( + alloc, + "Test", + pages, + pages.pages.last.?, + ); + defer search.deinit(); + + // Drain and feed incrementally until the boundary match is available. The + // exact number of feeds depends on how much blank-page text the formatter + // trims, so bound progress by the number of pages rather than encoding + // that implementation detail in the test. + var found: ?FlattenedHighlight = null; + for (0..pages.totalPages() + 1) |_| { + if (search.next()) |match| { + found = match; + break; + } + if (!try search.feed()) break; + } + try testing.expect(found != null); + + const match = found.?.untracked(); + try testing.expectEqual(first, match.start.node); + try testing.expectEqual(second, match.end.node); + try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x); + try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x); + + // Search owns formatted text and coordinate maps, not page snapshots. The + // original raw mappings therefore remain discarded after matching. + try testing.expectEqual(compressed, pages.memoryStats()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); +} + test "feed with match spanning page boundary with newline" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); @@ -367,7 +448,7 @@ test "feed with match spanning page boundary with newline" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -404,14 +485,14 @@ test "feed with pruned page" { // Grow to capacity const page1_node = p.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try p.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try p.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try p.grow() == null); } @@ -439,3 +520,31 @@ test "feed with pruned page" { // Feed should still do nothing try testing.expect(!try search.feed()); } + +test "feed keeps its tracked pin within a shorter page" { + const alloc = testing.allocator; + var pages: PageList = try .init(alloc, 10, 2, null); + defer pages.deinit(); + + const first = pages.pages.first.?; + while (first.rows() < first.capacity().rows) _ = try pages.grow(); + + const second = (try pages.grow()).?; + while (second.rows() < second.capacity().rows) _ = try pages.grow(); + + try pages.split(.{ .node = first, .y = 1, .x = 0 }); + const shorter = first.next.?; + try testing.expect(shorter.rows() < second.rows()); + + var search: PageListSearch = try .init( + alloc, + "x", + &pages, + second, + ); + defer search.deinit(); + + try testing.expect(try search.feed()); + try testing.expectEqual(shorter, search.pin.node); + try testing.expect(pages.pinIsValid(search.pin.*)); +} diff --git a/vendor/libghostty-vt/src/terminal/search/screen.zig b/vendor/libghostty-vt/src/terminal/search/screen.zig index 3e7e316f..e0cd9911 100644 --- a/vendor/libghostty-vt/src/terminal/search/screen.zig +++ b/vendor/libghostty-vt/src/terminal/search/screen.zig @@ -157,10 +157,29 @@ pub const ScreenSearch = struct { } pub fn deinit(self: *ScreenSearch) void { + self.deinitInternal(true); + } + + /// Release owned search state after the underlying Screen has already + /// been destroyed. Tracked pins belonged to that Screen's PageList pool + /// and were freed with it, so only independently owned memory is freed. + pub fn deinitScreenInvalid(self: *ScreenSearch) void { + self.deinitInternal(false); + } + + fn deinitInternal(self: *ScreenSearch, screen_valid: bool) void { const alloc = self.allocator(); self.active.deinit(); - if (self.history) |*h| h.deinit(self.screen); - if (self.selected) |*m| m.deinit(self.screen); + if (self.history) |*h| { + if (screen_valid) { + h.deinit(self.screen); + } else { + h.searcher.deinitListInvalid(); + } + } + if (screen_valid) { + if (self.selected) |*m| m.deinit(self.screen); + } for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); for (self.history_results.items) |*hl| hl.deinit(alloc); @@ -182,6 +201,33 @@ pub const ScreenSearch = struct { return self.active_results.items.len + self.history_results.items.len; } + /// Reinitialize cached search state after a screen resize. Reflow can + /// replace every PageList node, so even flattened highlights that weren't + /// selected contain invalid node pointers once either dimension changes. + /// + /// Returns true when the reset occurred. The replacement search has + /// already loaded its active area through `init` in that case. + fn resetIfDimensionsChanged(self: *ScreenSearch) Allocator.Error!bool { + if (self.screen.pages.rows == self.rows and + self.screen.pages.cols == self.cols) + { + return false; + } + + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + self.deinit(); + self.* = new; + + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + return true; + } + /// Returns all matches as an owned slice (caller must free). /// The matches are ordered from most recent to oldest (e.g. bottom /// of the screen to top of the screen). @@ -260,28 +306,9 @@ pub const ScreenSearch = struct { /// Feed on a complete screen search will perform some cleanup of /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { - // If the screen resizes, we have to reset our entire search. That - // isn't ideal but we don't have a better way right now to handle - // reflowing the search results beyond putting a tracked pin for - // every single result. - if (self.screen.pages.rows != self.rows or - self.screen.pages.cols != self.cols) - { - // Reinit - const new: ScreenSearch = try .init( - self.allocator(), - self.screen, - self.needle(), - ); - - // Deinit/reinit - self.deinit(); - self.* = new; - - // New result should have matching dimensions - assert(self.screen.pages.rows == self.rows); - assert(self.screen.pages.cols == self.cols); - } + // Resize/reflow invalidates every flattened result, not just search + // state that needs another history feed. + _ = try self.resetIfDimensionsChanged(); const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. @@ -317,22 +344,88 @@ pub const ScreenSearch = struct { } } + fn historyResultIsValid( + self: *const ScreenSearch, + hl: *const FlattenedHighlight, + ) bool { + const chunks = hl.chunks.slice(); + const nodes = chunks.items(.node); + const serials = chunks.items(.serial); + for (nodes, serials) |node, serial| { + if (!self.screen.pages.nodeIsValid(node, serial)) return false; + } + + return true; + } + + /// Clear a selected history match if its flattened page coordinates are + /// stale. reloadActive only inspects the selected history result, so this + /// avoids validating every cached result on each active-area refresh. + fn validateSelectedHistory(self: *ScreenSearch) void { + const m = if (self.selected) |*m| m else return; + const active_len = self.active_results.items.len; + if (m.idx < active_len) return; + + const history_idx = m.idx - active_len; + if (history_idx < self.history_results.items.len and + self.historyResultIsValid(&self.history_results.items[history_idx])) + { + return; + } + + m.deinit(self.screen); + self.selected = null; + } + fn pruneHistory(self: *ScreenSearch) void { // Go through our history results in order (newest to oldest) to find - // any result that contains an invalid serial. Prune up to that - // point. - for (0..self.history_results.items.len) |i| { + // any result that contains an invalid serial. + var i: usize = 0; + while (i < self.history_results.items.len) { const hl = &self.history_results.items[i]; - const serials = hl.chunks.items(.serial); - const lowest = serials[0]; - if (lowest < self.screen.pages.page_serial_min) { - // Everything from here forward we assume is invalid because - // our history results only get older. + const chunks = hl.chunks.slice(); + const serials = chunks.items(.serial); + const first_serial = serials[0]; + if (first_serial < self.screen.pages.page_serial_epoch) { + // Only a whole-list reset advances the epoch. Results are + // newest to oldest, so this result and the entire remaining + // suffix predate that reset. Drop them without scanning the + // live page list for every captured chunk. const alloc = self.allocator(); + if (self.selected) |*m| { + const first_pruned = self.active_results.items.len + i; + if (m.idx >= first_pruned) { + m.deinit(self.screen); + self.selected = null; + } + } for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); self.history_results.shrinkAndFree(alloc, i); return; } + + // Ordinary pruning, layout changes, and replacements do not + // advance the epoch. Validate their pointer-plus-generation pairs + // against the live list. + if (self.historyResultIsValid(hl)) { + i += 1; + continue; + } + + // Only this result is known to be invalid. Older results may live + // on unrelated pages and remain usable, so remove it individually. + const result_idx = self.active_results.items.len + i; + if (self.selected) |*m| { + if (m.idx == result_idx) { + m.deinit(self.screen); + self.selected = null; + } else if (m.idx > result_idx) { + m.idx -= 1; + } + } + + var removed = self.history_results.orderedRemove(i); + removed.deinit(self.allocator()); } } @@ -392,6 +485,14 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // This check must precede all inspection of cached highlights and + // searchers: column reflow may have freed every node they reference. + if (try self.resetIfDimensionsChanged()) return; + + // reloadActive only inspects the selected history result, so validate + // that result without scanning every cached match on this hot path. + self.validateSelectedHistory(); + const tw = reloadActive_tw; // If our selection pin became garbage it means we scrolled off @@ -696,9 +797,14 @@ pub const ScreenSearch = struct { /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { - // All selection requires valid pins so we prune history and - // reload our active area immediately. This ensures all search - // results point to valid nodes. + // A resize can replace any page node, including nodes retained by + // flattened history results. Reset before reloadActive or tracking a + // selected result so no pre-resize pointer is ever dereferenced. + _ = try self.resetIfDimensionsChanged(); + + // Reload validates the selected history result before inspecting its + // cached coordinates. Prune the remaining results afterward so every + // candidate is valid before selection tracks it. try self.reloadActive(); self.pruneHistory(); @@ -798,7 +904,7 @@ pub const ScreenSearch = struct { const active_len = self.active_results.items.len; const history_len = self.history_results.items.len; - const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len; + const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len + history_len - 1; const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] @@ -1090,6 +1196,40 @@ test "select next" { } } +test "select after resize resets stale flattened results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expect(try search.select(.next)); + try testing.expect(search.selectedMatch() != null); + try testing.expectEqual(@as(size.CellCountInt, 10), search.cols); + + // Column reflow replaces the nodes referenced by the cached flattened + // active result. select() calls reloadActive(), so dimension invalidation + // must run before either path dereferences those old nodes. + try t.screens.active.pages.resize(.{ .cols = 5 }); + try testing.expectEqual(@as(size.CellCountInt, 10), search.cols); + + try testing.expect(try search.select(.next)); + try testing.expectEqual(@as(size.CellCountInt, 5), search.cols); + + const selected = search.selectedMatch().?.untracked(); + try testing.expectEqual(@as(size.CellCountInt, 5), selected.start.node.cols()); + try testing.expectEqual(@as(size.CellCountInt, 5), selected.end.node.cols()); +} + test "select in active changes contents completely" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); @@ -1380,6 +1520,276 @@ test "select prev with history" { } } +test "select prev wraps when all matches are in history" { + // Regression test: when every match is in scrollback (the active area + // has none, so active_len == 0), selecting prev from index 0 must wrap + // to the last result without underflowing `active_len - 1`. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + // Put the only match in scrollback, then scroll the active area to all + // blank lines so it contains no match (active_len == 0, history_len == 1). + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Select the first match (idx 0), then wrap backwards. This must not + // panic and must keep a valid selection. + _ = try search.select(.next); + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() != null); +} + +test "select after all matches disappear drops the selection" { + // The wrap arithmetic in selectPrev (active_len + history_len - 1) would + // underflow if a selection were ever live while both result lists are + // empty. This guards the invariant that makes that unreachable: when a + // reload/prune empties the results, the selection is dropped, so the next + // select() hits the "no matches" guard instead of the wrap arithmetic. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("Fizz"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // Take a selection, then overwrite the only match so a reload finds none + // (active and history both empty). + _ = try search.select(.next); + try testing.expect(search.selectedMatch() != null); + s.nextSlice("\x1b[1;1H "); + + // Must not underflow; the selection is dropped and nothing is selected. + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() == null); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(0, search.history_results.items.len); +} + +test "select after partial history erase drops a pruned selection" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice("error\r\n"); + const first = list.pages.first.?; + while (list.totalPages() < 3) stream.nextSlice("\r\n"); + const first_rows = first.rows(); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(1, search.history_results.items.len); + try testing.expect(try search.select(.next)); + + list.eraseHistory(.{ .history = .{ .y = first_rows - 1 } }); + try testing.expect(!search.selected.?.highlight.start.garbage); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "select after history compaction ignores replaced results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice("error\r\n"); + const first = list.pages.first.?; + while (list.totalPages() < 3) stream.nextSlice("\r\n"); + try list.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + const replacement = (try list.compact(first)).?; + try testing.expect(replacement != first); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "select after partial history page erase ignores shifted results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + try testing.expect(first != list.pages.last.?); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(1, search.history_results.items.len); + + const old_rows = first.rows(); + list.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expectEqual(old_rows - 1, first.rows()); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "reload defers pruning unselected history results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + list.eraseHistory(.{ .history = .{ .y = 0 } }); + + // Routine active refreshes don't inspect unselected history results. + try search.reloadActive(); + try testing.expectEqual(1, search.history_results.items.len); + + // Selection prunes every candidate before attempting to track it. + try testing.expect(!try search.select(.next)); + try testing.expectEqual(0, search.history_results.items.len); +} + +test "reload after partial history page erase drops shifted selection first" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expect(try search.select(.next)); + try testing.expect(search.selected != null); + + list.eraseHistory(.{ .history = .{ .y = 0 } }); + + // Advance the active boundary into another node and add matches so + // reloadActive updates the history result offsets. It must discard the + // stale selected result before comparing any of its captured coordinates. + const active_node = list.getTopLeft(.active).node; + while (list.getTopLeft(.active).node == active_node) { + stream.nextSlice("error\r\n"); + } + + try search.reloadActive(); + try testing.expect(search.selected == null); +} + +test "select after history page split ignores moved results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + try list.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + test "screen search no scrollback has no history" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ diff --git a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig b/vendor/libghostty-vt/src/terminal/search/sliding_window.zig index 93a606fd..2f858ec8 100644 --- a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig +++ b/vendor/libghostty-vt/src/terminal/search/sliding_window.zig @@ -89,6 +89,7 @@ pub const SlidingWindow = struct { const Meta = struct { node: *PageList.List.Node, serial: u64, + rows: size.CellCountInt, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -96,6 +97,17 @@ pub const SlidingWindow = struct { } }; + /// Information copied from a page while appending it to the window. + /// + /// Both values remain safe after the node's preserved page is released. + /// In particular, callers use `last_row_wrapped` to decide whether an + /// adjacent page can contribute to a cross-page match without reading the + /// node again. + pub const AppendResult = struct { + content_len: usize, + last_row_wrapped: bool, + }; + pub fn init( alloc: Allocator, direction: Direction, @@ -164,6 +176,11 @@ pub const SlidingWindow = struct { /// or `append()`. If the caller wants to retain the flattened highlight /// then they should clone it. pub fn next(self: *SlidingWindow) ?FlattenedHighlight { + // An empty needle represents an inactive search. Searching for it + // would otherwise produce a zero-length match, which highlight() + // cannot represent because its end offset is inclusive. + if (self.needle.len == 0) return null; + const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -309,6 +326,12 @@ pub const SlidingWindow = struct { self.chunk_buf.clearRetainingCapacity(); var result: terminal.highlight.Flattened = .empty; + // A reverse cross-page match needs the row count of the last meta in + // search order after the chunks themselves are reversed. Snapshot it + // while traversing Meta rather than dereferencing a PageList node after + // the terminal lock has been released. + var cross_page_end_rows: ?size.CellCountInt = null; + // Go through the meta nodes to find our start. const tl: struct { /// If non-null, we need to continue searching for the bottom-right. @@ -375,7 +398,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = @intCast(map.y), - .end = meta.node.data.size.rows, + .end = meta.rows, }); break :tl .{ @@ -410,7 +433,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = 0, - .end = meta.node.data.size.rows, + .end = meta.rows, }); meta_consumed += meta.cell_map.items.len; @@ -420,6 +443,7 @@ pub const SlidingWindow = struct { // We found it const map = meta.cell_map.items[meta_i]; result.bot_x = map.x; + cross_page_end_rows = meta.rows; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, .serial = meta.serial, @@ -465,12 +489,14 @@ pub const SlidingWindow = struct { .reverse => { const slice = self.chunk_buf.slice(); const nodes = slice.items(.node); + const serials = slice.items(.serial); const starts = slice.items(.start); const ends = slice.items(.end); if (self.chunk_buf.len > 1) { // Reverse all our chunks. This should be pretty obvious why. std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(u64, serials); std.mem.reverse(size.CellCountInt, starts); std.mem.reverse(size.CellCountInt, ends); @@ -491,7 +517,7 @@ pub const SlidingWindow = struct { // order. assert(nodes.len >= 2); starts[0] = ends[0] - 1; - ends[0] = nodes[0].data.size.rows; + ends[0] = cross_page_end_rows.?; ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; } else { @@ -526,11 +552,49 @@ pub const SlidingWindow = struct { pub fn append( self: *SlidingWindow, node: *PageList.List.Node, - ) Allocator.Error!usize { + ) Allocator.Error!AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + return self.appendPage(node, page, last_row_wrapped); + } + + /// Append a node only when its last row is soft wrapped. + /// + /// This acquires one preserved page for both the wrap check and formatting + /// so a compressed node is decoded at most once. It is used when loading + /// the overlap around an otherwise complete search region. + pub fn appendIfWrapped( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!?AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + if (!last_row_wrapped) return null; + return try self.appendPage(node, page, last_row_wrapped); + } + + /// Copy one preserved page into the window's owned search buffers. + /// + /// No pointer into `page` may escape this function: compressed preserved + /// pages own temporary decode storage, while resident values borrow node + /// memory. + fn appendPage( + self: *SlidingWindow, + node: *PageList.List.Node, + page: *const terminal.Page, + last_row_wrapped: bool, + ) Allocator.Error!AppendResult { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, .serial = node.serial, + .rows = page.size.rows, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -544,7 +608,7 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .{ + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, }); @@ -563,8 +627,7 @@ pub const SlidingWindow = struct { // If the node we're adding isn't soft-wrapped, we add the // trailing newline. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) { + if (!last_row_wrapped) { encoded.writer.writeByte('\n') catch return error.OutOfMemory; try meta.cell_map.append( self.alloc, @@ -580,7 +643,10 @@ pub const SlidingWindow = struct { const written = encoded.written(); if (written.len == 0) { self.assertIntegrity(); - return 0; + return .{ + .content_len = 0, + .last_row_wrapped = last_row_wrapped, + }; } // Get our written data. If we're doing a reverse search then we @@ -603,7 +669,10 @@ pub const SlidingWindow = struct { self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); - return written.len; + return .{ + .content_len = written.len, + .last_row_wrapped = last_row_wrapped, + }; } /// Only for tests! @@ -642,6 +711,25 @@ test "SlidingWindow empty on init" { try testing.expectEqual(0, w.meta.len()); } +test "SlidingWindow empty needle has no matches" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, ""); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + try s.testWriteString("hello"); + + _ = try w.append(s.pages.pages.first.?); + try testing.expectEqual(null, w.next()); +} + test "SlidingWindow single append" { const testing = std.testing; const alloc = testing.allocator; @@ -813,7 +901,7 @@ test "SlidingWindow two pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -868,7 +956,7 @@ test "SlidingWindow two pages single char" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -923,7 +1011,7 @@ test "SlidingWindow two pages match across boundary" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -968,7 +1056,7 @@ test "SlidingWindow two pages no match across boundary with newline" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1001,7 +1089,7 @@ test "SlidingWindow two pages no match across boundary with newline reverse" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1031,7 +1119,7 @@ test "SlidingWindow two pages no match prunes first page" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1063,7 +1151,7 @@ test "SlidingWindow two pages no match keeps both pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1164,7 +1252,7 @@ test "SlidingWindow single append match on boundary" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1290,7 +1378,7 @@ test "SlidingWindow two pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1345,7 +1433,7 @@ test "SlidingWindow two pages match across boundary reversed" { // Fill up the first page. The final bytes in the first page // are "hell" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1361,6 +1449,15 @@ test "SlidingWindow two pages match across boundary reversed" { // Search should find a match { const h = w.next().?; + const chunks = h.chunks.slice(); + const nodes = chunks.items(.node); + const serials = chunks.items(.serial); + try testing.expectEqual(2, chunks.len); + try testing.expectEqual(node, nodes[0]); + try testing.expectEqual(node.serial, serials[0]); + try testing.expectEqual(node.next.?, nodes[1]); + try testing.expectEqual(node.next.?.serial, serials[1]); + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, @@ -1391,7 +1488,7 @@ test "SlidingWindow two pages no match prunes first page reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1423,7 +1520,7 @@ test "SlidingWindow two pages no match keeps both pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1525,7 +1622,7 @@ test "SlidingWindow single append match on boundary reversed" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1665,7 +1762,7 @@ test "SlidingWindow append whitespace only node" { // This is invasive but its otherwise hard to reproduce naturally // without creating a slow test. const node: *PageList.List.Node = s.pages.pages.first.?; - const last_row = node.data.getRow(node.data.size.rows - 1); + const last_row = node.page().getRow(node.rows() - 1); last_row.wrap = true; try testing.expect(s.pages.pages.first == s.pages.pages.last); diff --git a/vendor/libghostty-vt/src/terminal/search/viewport.zig b/vendor/libghostty-vt/src/terminal/search/viewport.zig index 35dd9331..09d202be 100644 --- a/vendor/libghostty-vt/src/terminal/search/viewport.zig +++ b/vendor/libghostty-vt/src/terminal/search/viewport.zig @@ -24,7 +24,7 @@ const Terminal = @import("../Terminal.zig"); /// lives in the same page. pub const ViewportSearch = struct { window: SlidingWindow, - fingerprint: ?Fingerprint, + fingerprint: Fingerprint = .{}, /// If this is null, then active dirty tracking is disabled and if the /// viewport overlaps the active area we always re-search. If this is @@ -43,21 +43,19 @@ pub const ViewportSearch = struct { errdefer window.deinit(); return .{ .window = window, - .fingerprint = null, .active_dirty = null, }; } pub fn deinit(self: *ViewportSearch) void { - if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.fingerprint.deinit(self.window.alloc); self.window.deinit(); } /// Reset our fingerprint and results so that the next update will /// always re-search. pub fn reset(self: *ViewportSearch) void { - if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); - self.fingerprint = null; + self.fingerprint.clearAndRetainCapacity(); self.window.clearAndRetainCapacity(); } @@ -80,48 +78,37 @@ pub const ViewportSearch = struct { self: *ViewportSearch, list: *PageList, ) Allocator.Error!bool { - // See if our viewport changed - var fingerprint: Fingerprint = try .init(self.window.alloc, list); - if (self.fingerprint) |*old| { - if (old.eql(fingerprint)) match: { - // Determine if we need to check if we overlap the active - // area. If we have dirty tracking on we also set it to - // false here. - const check_active: bool = active: { - const dirty = self.active_dirty orelse break :active true; - if (!dirty) break :active false; - self.active_dirty = false; - break :active true; - }; - - if (check_active) { - // If our fingerprint contains the active area, then we always - // re-search since the active area is mutable. - const active_tl = list.getTopLeft(.active); - const active_br = list.getBottomRight(.active).?; - - // If our viewport contains the start or end of the active area, - // we are in the active area. We purposely do this first - // because our viewport is always larger than the active area. - for (old.nodes) |node| { - if (node == active_tl.node) break :match; - if (node == active_br.node) break :match; - } + // See if our viewport changed. We retain the fingerprint buffer so + // unchanged viewports don't allocate and changed viewports usually + // reuse the previous capacity. + const fingerprint = &self.fingerprint; + if (!try fingerprint.update(self.window.alloc, list)) match: { + // Determine if we need to check if we overlap the active area. + // If we have dirty tracking on we also set it to false here. + const check_active: bool = active: { + const dirty = self.active_dirty orelse break :active true; + if (!dirty) break :active false; + self.active_dirty = false; + break :active true; + }; + + if (check_active) { + // If our fingerprint contains the active area, then we always + // re-search since the active area is mutable. + const active_tl = list.getTopLeft(.active); + const active_br = list.getBottomRight(.active).?; + + // If our viewport contains the start or end of the active area, + // we are in the active area. We purposely do this first because + // our viewport is always larger than the active area. + for (fingerprint.entries.items) |entry| { + if (entry.node == active_tl.node) break :match; + if (entry.node == active_br.node) break :match; } - - // No change - fingerprint.deinit(self.window.alloc); - return false; } - old.deinit(self.window.alloc); - self.fingerprint = null; - } - assert(self.fingerprint == null); - self.fingerprint = fingerprint; - errdefer { - fingerprint.deinit(self.window.alloc); - self.fingerprint = null; + // No change + return false; } // If our active area was set as dirty, we always unset it here @@ -134,40 +121,40 @@ pub const ViewportSearch = struct { // Add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. We only do this for the // soft-wrapped prior pages. - var node_ = fingerprint.nodes[0].prev; + const overlap_len = self.window.needle.len -| 1; + const entries = fingerprint.entries.items; + var node_ = entries[0].node.prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - added += try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + added += appended.content_len; + if (added >= overlap_len) break; } // We can use our fingerprint nodes to initialize our sliding // window, since we already traversed the viewport once. - for (fingerprint.nodes) |node| { - _ = try self.window.append(node); + var end_append: SlidingWindow.AppendResult = undefined; + for (entries) |entry| { + end_append = try self.window.append(entry.node); } // Add any trailing overlap as well. trailing: { - const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; - if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing; + const end: *PageList.List.Node = entries[entries.len - 1].node; + if (!end_append.last_row_wrapped) break :trailing; node_ = end.next; added = 0; while (node_) |node| : (node_ = node.next) { - added += try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.append(node); + added += appended.content_len; + if (added >= overlap_len) break; // If this row doesn't wrap, then we can quit - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; + if (!appended.last_row_wrapped) break; } } @@ -182,36 +169,59 @@ pub const ViewportSearch = struct { /// Viewport fingerprint so we can detect when the viewport moves. const Fingerprint = struct { - /// The nodes that make up the viewport. We need to flatten this - /// to a single list because we can't safely traverse the cached values - /// because the page nodes may be invalid. All that is safe is comparing - /// the actual pointer values. - nodes: []const *PageList.List.Node, - - pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint { - var list: std.ArrayList(*PageList.List.Node) = .empty; - defer list.deinit(alloc); - - // Get our viewport area. Bottom right of a viewport can never - // fail. - const tl = pages.getTopLeft(.viewport); - const br = pages.getBottomRight(.viewport).?; - - var it = tl.pageIterator(.right_down, br); - while (it.next()) |chunk| try list.append(alloc, chunk.node); - return .{ .nodes = try list.toOwnedSlice(alloc) }; - } + /// The node addresses and generations that make up the viewport. We + /// can't safely dereference cached node pointers because the nodes may + /// be invalid, so equality only compares these captured values. + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const Entry = struct { + node: *PageList.List.Node, + serial: u64, + }; pub fn deinit(self: *Fingerprint, alloc: Allocator) void { - alloc.free(self.nodes); + self.entries.deinit(alloc); + } + + pub fn clearAndRetainCapacity(self: *Fingerprint) void { + self.entries.clearRetainingCapacity(); } - pub fn eql(self: Fingerprint, other: Fingerprint) bool { - if (self.nodes.len != other.nodes.len) return false; - for (self.nodes, other.nodes) |a, b| { - if (a != b) return false; + /// Update this fingerprint to match the current viewport. + /// + /// Returns true if the viewport changed. The viewport can have at + /// most one entry per visible row, so we reserve that upper bound + /// before mutating the retained buffer. This lets us rebuild in one + /// pass while still preserving the old fingerprint if allocation + /// fails. + pub fn update(self: *Fingerprint, alloc: Allocator, pages: *PageList) Allocator.Error!bool { + try self.entries.ensureTotalCapacity(alloc, pages.rows); + + const old_len = self.entries.items.len; + var changed = false; + + var i: usize = 0; + var it = pages.pageIterator(.right_down, .{ .viewport = .{} }, null); + while (it.next()) |chunk| : (i += 1) { + const entry: Entry = .{ + .node = chunk.node, + .serial = chunk.node.serial, + }; + if (!changed) { + changed = i >= old_len or + self.entries.items[i].node != entry.node or + self.entries.items[i].serial != entry.serial; + } + + if (i < self.entries.items.len) { + self.entries.items[i] = entry; + } else { + self.entries.appendAssumeCapacity(entry); + } } - return true; + + self.entries.shrinkRetainingCapacity(i); + return changed or i != old_len; } }; }; @@ -259,6 +269,55 @@ test "simple search" { try testing.expect(search.next() == null); } +test "fingerprint detects node generation changes" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var search: ViewportSearch = try .init(alloc, "needle"); + defer search.deinit(); + search.active_dirty = false; + + try testing.expect(try search.update(&t.screens.active.pages)); + try testing.expect(!try search.update(&t.screens.active.pages)); + + const old_entry = search.fingerprint.entries.items[0]; + const node = t.screens.active.pages.pages.first.?; + try testing.expectEqual(node, old_entry.node); + const original_serial = node.serial; + defer node.serial = original_serial; + node.serial += 1; + + // The viewport still contains the same pointer, but its generation has + // changed, so the cached window must be rebuilt. + try testing.expect(try search.update(&t.screens.active.pages)); + const new_entry = search.fingerprint.entries.items[0]; + try testing.expectEqual(old_entry.node, new_entry.node); + try testing.expect(old_entry.serial != new_entry.serial); +} + +test "fingerprint only allocates on changes" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var failing = testing.FailingAllocator.init(alloc, .{}); + const failing_alloc = failing.allocator(); + + var search: ViewportSearch = try .init(failing_alloc, "needle"); + defer search.deinit(); + search.active_dirty = false; + + try testing.expect(try search.update(&t.screens.active.pages)); + + // Fail the next allocation. An unchanged viewport with active dirty + // tracking disabled should reuse the existing fingerprint and return + // unchanged before rebuilding the search window, so this update should + // succeed without reporting a viewport change. + failing.fail_index = failing.alloc_index; + try testing.expect(!try search.update(&t.screens.active.pages)); +} + test "clear screen and search" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); @@ -348,7 +407,7 @@ test "history search, no active area" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; s.nextSlice("Fizz\r\n"); for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig index 9771334f..efb06ecd 100644 --- a/vendor/libghostty-vt/src/terminal/stream.zig +++ b/vendor/libghostty-vt/src/terminal/stream.zig @@ -33,6 +33,7 @@ const debug = false; /// function for handling. pub const Action = union(Key) { print: Print, + print_slice: PrintSlice, print_repeat: usize, bell, backspace, @@ -110,6 +111,7 @@ pub const Action = union(Key) { apc_start, apc_end, apc_put: u8, + apc_put_slice: ApcPutSlice, end_hyperlink, active_status_display: ansi.StatusDisplay, decaln, @@ -130,6 +132,7 @@ pub const Action = union(Key) { lib.target, &.{ "print", + "print_slice", "print_repeat", "bell", "backspace", @@ -207,6 +210,7 @@ pub const Action = union(Key) { "apc_start", "apc_end", "apc_put", + "apc_put_slice", "end_hyperlink", "active_status_display", "decaln", @@ -252,6 +256,40 @@ pub const Action = union(Key) { } }; + /// A run of printable codepoints. This is emitted instead of + /// individual print actions when the stream can decode multiple + /// printable codepoints at once, so handlers can process them in + /// batch with per-run rather than per-codepoint overhead (see + /// Terminal.printSlice). A naive handler can simply loop and + /// handle each codepoint like a print action. + /// + /// The slice is only valid for the duration of the handler call. + pub const PrintSlice = struct { + cps: []const u32, + + pub const C = extern struct { + cps: [*]const u32, + len: usize, + }; + + pub fn cval(self: PrintSlice) PrintSlice.C { + return .{ .cps = self.cps.ptr, .len = self.cps.len }; + } + }; + + pub const ApcPutSlice = struct { + bytes: []const u8, + + pub const C = extern struct { + bytes: [*]const u8, + len: usize, + }; + + pub fn cval(self: ApcPutSlice) ApcPutSlice.C { + return .{ .bytes = self.bytes.ptr, .len = self.bytes.len }; + } + }; + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -411,6 +449,11 @@ pub const Action = union(Key) { /// about in its pursuit of implementing a terminal emulator or other /// functionality. /// +/// Note that printable text is delivered via `print_slice` actions +/// (runs of codepoints) whenever the stream can decode multiple +/// codepoints at once, and via `print` actions otherwise. Handlers +/// that care about text must handle both. +/// /// The Handler type must also have a `deinit` function. /// /// The "comptime" key is on purpose (vs. a standard Zig tagged union) @@ -521,12 +564,25 @@ pub fn Stream(comptime H: type) type { // up to that point are just UTF-8. while (self.parser.state == .ground and offset < input.len) { const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); - for (cp_buf[0..res.decoded]) |cp| { + const cps = cp_buf[0..res.decoded]; + + // Hand runs of printable codepoints to the handler as + // print_slice actions so it can process them with + // per-run rather than per-codepoint overhead. + var i: usize = 0; + while (i < cps.len) { + const cp = cps[i]; if (cp <= 0xF) { + @branchHint(.unlikely); self.execute(@intCast(cp)); - } else { - self.print(@intCast(cp)); + i += 1; + continue; } + + var end = i + 1; + while (end < cps.len and cps[end] > 0xF) end += 1; + self.handler.vt(.print_slice, .{ .cps = cps[i..end] }); + i = end; } // Consume the bytes we just processed. offset += res.consumed; @@ -570,12 +626,202 @@ pub fn Stream(comptime H: type) type { var offset: usize = 0; while (self.parser.state != .ground) { if (offset >= input.len) return input.len; + + // Fast path for CSI entry: "ESC [" is by far the most + // common escape sequence prefix, so handle the '[' and + // the byte that follows it here rather than paying a + // nextNonUtf8 call for each. + if (self.parser.state == .escape and input[offset] == '[') { + self.parser.state = .csi_entry; + offset += 1; + continue; + } + + if (comptime !@hasDecl(T, "vtRaw")) { + if (self.parser.state == .csi_entry) { + if (self.csiEntryByte(input[offset])) { + offset += 1; + continue; + } + } + + // Bulk-consume CSI parameter bytes. This can't be + // used for handlers with a vtRaw hook because it + // dispatches the CSI directly (see nextNonUtf8). + if (self.parser.state == .csi_param) { + offset += self.consumeCsiParams(input[offset..]); + if (offset >= input.len) return input.len; + // If we're still in csi_param then the next byte + // isn't a parameter byte; let nextNonUtf8 below + // handle it. Otherwise re-check our state. + if (self.parser.state != .csi_param) continue; + } + + // Bulk-consume APC string bytes into a single slice. + // APC payloads (e.g. Kitty graphics) can be megabytes + // of base64 data, so per-byte dispatch is far too slow. + // This can't be used for handlers with a vtRaw hook + // because it dispatches the slice directly. + if (self.parser.state == .sos_pm_apc_string) { + offset += self.consumeApcString(input[offset..]); + if (offset >= input.len) return input.len; + // The next byte exits the string state; let + // nextNonUtf8 below handle it. + } + } + self.nextNonUtf8(input[offset]); offset += 1; } return offset; } + /// Fast path for a byte in the csi_entry state, the state right + /// after "ESC [". Virtually every CSI sequence spends exactly + /// one byte in this state, on either a digit, a private marker, + /// or a final byte. Returns true if the byte was fully handled; + /// false means the caller must process it through the general + /// state machine. + /// + /// Must not be used by handlers with a vtRaw hook because the + /// final byte case dispatches the CSI directly. + inline fn csiEntryByte(self: *Self, c: u8) bool { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .csi_entry); + switch (c) { + // First parameter digit. + '0'...'9' => { + self.parser.state = .csi_param; + // param_acc is zero (cleared on escape entry) + // so accumulating is just the digit value. + self.parser.param_acc = c - '0'; + self.parser.param_acc_idx = 1; + }, + // An empty first parameter. + ';' => { + self.parser.state = .csi_param; + self.parser.params[0] = 0; + self.parser.params_idx = 1; + }, + // Private marker (e.g. '?' in "ESC [ ? 2004 h"). + 0x3C...0x3F => { + self.parser.state = .csi_param; + self.parser.collect(c); + }, + // A final byte: a parameterless CSI. + 0x40...0x7E => self.csiDispatchFinal(c), + // Defer to the state machine for anything else + // (C0 controls, intermediates, colon). + else => return false, + } + return true; + } + + /// Bulk-consume CSI parameter bytes (digits and separators) + /// and, if reached, the final byte (dispatching the CSI). + /// Returns the number of bytes consumed. Stops at the first + /// byte that isn't handled here, leaving the parser in the + /// csi_param state so the caller can process that byte. + fn consumeCsiParams(self: *Self, input: []const u8) usize { + const p = &self.parser; + assert(p.state == .csi_param); + + // Accumulate parser state in locals for the hot loop. + var acc = p.param_acc; + var acc_idx = p.param_acc_idx; + var idx = p.params_idx; + + var offset: usize = 0; + while (offset < input.len) { + const c = input[offset]; + switch (c) { + // A parameter digit. + '0'...'9' => { + if (idx < Parser.MAX_PARAMS) { + acc *|= 10; + acc +|= c - '0'; + acc_idx |= 1; + } + offset += 1; + }, + + // A parameter separator. + ':', ';' => { + if (idx < Parser.MAX_PARAMS) { + p.params[idx] = acc; + if (c == ':') p.params_sep.set(idx); + idx += 1; + acc = 0; + acc_idx = 0; + } + offset += 1; + }, + + // A final byte: dispatch the CSI. + 0x40...0x7E => { + p.param_acc = acc; + p.param_acc_idx = acc_idx; + p.params_idx = idx; + self.csiDispatchFinal(c); + return offset + 1; + }, + + // Anything else (C0 controls, intermediates, etc.) + // is handled by the caller. + else => break, + } + } + + p.param_acc = acc; + p.param_acc_idx = acc_idx; + p.params_idx = idx; + return offset; + } + + /// Bulk-consume APC string bytes and dispatch them as a single + /// apc_put_slice action. Returns the number of bytes consumed. + /// Stops at the first byte that is not an apc_put byte in the + /// parse table, leaving it for the caller to process through + /// the state machine. CAN, SUB, ESC, and most C1 bytes exit + /// or abort the string state; 0xA0-0xFF are ignored by the + /// table (not payload), so they can't be bulk-consumed either. + /// + /// Must not be used by handlers with a vtRaw hook because it + /// dispatches the slice directly. + fn consumeApcString(self: *Self, input: []const u8) usize { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .sos_pm_apc_string); + + var end: usize = 0; + if (comptime std.simd.suggestVectorLength(u8)) |vector_len| { + const ByteVector = @Vector(vector_len, u8); + while (end + vector_len <= input.len) { + const bytes: ByteVector = input[end..][0..vector_len].*; + const invalid = (bytes == @as(ByteVector, @splat(0x18))) | + (bytes == @as(ByteVector, @splat(0x1A))) | + (bytes == @as(ByteVector, @splat(0x1B))) | + (bytes >= @as(ByteVector, @splat(0x80))); + if (@reduce(.Or, invalid)) break; + end += vector_len; + } + } + while (end < input.len) { + switch (input[end]) { + // Not apc_put bytes: CAN/SUB/ESC and most C1 exit + // or abort the state; 0xA0-0xFF are ignored by it. + 0x18, 0x1A, 0x1B, 0x80...0xFF => break, + // Everything else is an apc_put byte. + else => end += 1, + } + } + + if (end > 0) self.handler.vt( + .apc_put_slice, + .{ .bytes = input[0..end] }, + ); + return end; + } + /// Like nextSlice but takes one byte and is necessarily a scalar /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. @@ -654,6 +900,13 @@ pub fn Stream(comptime H: type) type { self.parser.state = .csi_entry; return; } + + // The fast paths below dispatch actions directly rather than + // going through Parser.next, so they'd bypass a handler's + // vtRaw hook. Handlers with vtRaw (e.g. the inspector) use + // the general path for anything that produces an action. + const has_vt_raw = comptime @hasDecl(T, "vtRaw"); + // Fast path for CSI params. if (self.parser.state == .csi_param) csi_param: { // csi_param is the most common parser state @@ -690,6 +943,10 @@ pub fn Stream(comptime H: type) type { self.parser.param_acc = 0; self.parser.param_acc_idx = 0; }, + // A final byte: dispatch the CSI directly. + 0x40...0x7E => if (comptime !has_vt_raw) { + self.csiDispatchFinal(c); + } else break :csi_param, // Explicitly ignored: 0x7F => {}, // Defer to the state machine to @@ -699,6 +956,13 @@ pub fn Stream(comptime H: type) type { return; } + // Fast path for CSI entry, the state right after "ESC [". + if (comptime !has_vt_raw) { + if (self.parser.state == .csi_entry) { + if (self.csiEntryByte(c)) return; + } + } + // We explicitly inline this call here for performance reasons. // // We do this rather than mark Parser.next as inline because doing @@ -744,6 +1008,48 @@ pub fn Stream(comptime H: type) type { } } + /// Finalize and dispatch a CSI directly from parser state for + /// the fast paths in nextNonUtf8, without going through + /// Parser.next. This must match the behavior of the parser's + /// csi_dispatch action. + fn csiDispatchFinal(self: *Self, c: u8) void { + const p = &self.parser; + p.state = .ground; + + // Ignore sequences with too many parameters, matching the + // parser's behavior of dropping the dispatch entirely. + if (p.params_idx >= Parser.MAX_PARAMS) { + @branchHint(.unlikely); + return; + } + + // Finalize the last parameter if we have one. + if (p.param_acc_idx > 0) { + p.params[p.params_idx] = p.param_acc; + p.params_idx += 1; + } + + const action: Parser.Action.CSI = .{ + .intermediates = p.intermediates[0..p.intermediates_idx], + .params = p.params[0..p.params_idx], + .params_sep = p.params_sep, + .final = c, + }; + + // We only allow colon or mixed separators for the 'm' command. + if (c != 'm' and p.params_sep.count() > 0) { + @branchHint(.cold); + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {f}", + .{action}, + ); + return; + } + + if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }}); + self.csiDispatch(action); + } + inline fn print(self: *Self, c: u21) void { self.handler.vt(.print, .{ .cp = c }); } @@ -776,7 +1082,7 @@ pub fn Stream(comptime H: type) type { .SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), .SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), - else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), + else => logUnsupportedOnce("invalid C0 character, ignoring: 0x{x}", .{c}, c), } } @@ -1294,7 +1600,11 @@ pub fn Stream(comptime H: type) type { if (req) |r| { self.handler.vt(.device_attributes, r); } else { - log.warn("invalid device attributes command: {f}", .{input}); + logUnsupportedOnce( + "invalid device attributes command: {f}", + .{input}, + if (input.params.len > 0) input.params[0] else 0, + ); return; } }, @@ -1380,7 +1690,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.set_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1401,7 +1711,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.reset_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1470,9 +1780,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.modify_key_format, format); }, - else => log.warn( + else => logUnsupportedOnce( "unknown CSI m with intermediate: {}", .{input.intermediates[0]}, + input.intermediates[0], ), }, @@ -1804,13 +2115,15 @@ pub fn Stream(comptime H: type) type { 23 => self.handler.vt(.title_pop, index), else => @compileError("unreachable"), } - } else log.warn( + } else logUnsupportedOnce( "ignoring CSI 22/23 t with extra parameters: {f}", .{input}, + input.params[0], ), - else => log.warn( + else => logUnsupportedOnce( "ignoring CSI t with unimplemented parameter: {f}", .{input}, + input.params[0], ), } } else log.err( @@ -1985,7 +2298,11 @@ pub fn Stream(comptime H: type) type { .change_window_icon => |icon| { @branchHint(.likely); - log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); + logUnsupportedOnce( + "OSC 1 (change icon) received and ignored icon={s}", + .{icon}, + 0, + ); }, .clipboard_contents => |clip| { @@ -2058,6 +2375,7 @@ pub fn Stream(comptime H: type) type { .conemu_run_process, .kitty_text_sizing, .kitty_clipboard_protocol, + .kitty_dnd_protocol, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); @@ -2176,7 +2494,11 @@ pub fn Stream(comptime H: type) type { else => {}, // fall through } - log.warn("unimplemented ESC action: {f}", .{action}); + logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ); }, // IND - Index @@ -2370,12 +2692,72 @@ pub fn Stream(comptime H: type) type { @branchHint(.likely); }, - else => log.warn("unimplemented ESC action: {f}", .{action}), + else => logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ), } } }; } +/// Logs an unsupported-input message at most once per distinct key +/// per process. +/// +/// These messages are emitted in response to input that the terminal +/// application controls, so a misbehaving (or merely chatty) program +/// can trigger the same message millions of times, e.g. by toggling +/// an unimplemented mode on every frame. Each log call has a real +/// throughput cost (formatting plus a blocking write per message) +/// while adding no diagnostic value beyond the first occurrence. +/// +/// The keys seen so far are tracked in a small fixed table (64 bytes) +/// instantiated per (format, argument type) tuple, i.e. roughly per +/// call site. Real streams only ever produce a handful of distinct +/// unsupported values per site, so if the table ever fills, messages +/// for further new values are suppressed as well: by that point the +/// log already shows this class of problem and unbounded distinct +/// values would flood it anyway. +fn logUnsupportedOnce( + comptime format: []const u8, + args: anytype, + key: u16, +) void { + // u32 slots so every u16 key is representable alongside an empty + // sentinel and so 32-bit targets (e.g. wasm32) have native + // atomics. + const empty = std.math.maxInt(u32); + const Static = struct { + var seen: [16]u32 = @splat(empty); + }; + + // The atomics make concurrent streams safe: slots are only ever + // claimed, never changed, so the scan can stop at the first empty + // slot. The worst case race is a benign duplicate message. + for (&Static.seen) |*slot| { + const cur = @atomicLoad(u32, slot, .acquire); + if (cur == key) return; // already logged + if (cur != empty) continue; // other key, keep scanning + + // Empty slot: claim it for this key and log below. + const actual = @cmpxchgStrong( + u32, + slot, + empty, + key, + .acq_rel, + .acquire, + ) orelse break; + + // Lost the race: suppress if it was to the same key, keep + // scanning otherwise. + if (actual == key) return; + } else return; // table full: suppress new values too + + log.warn(format, args); +} + test Action { // Forces the C type to be reified when the target is C, ensuring // all our types are C ABI compatible. @@ -2414,6 +2796,7 @@ test "simd: print invalid utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } @@ -2435,6 +2818,7 @@ test "simd: complete incomplete utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } @@ -3447,3 +3831,130 @@ test "stream: tab clear with overflowing param" { // CSI with a huge numeric param that saturates to 65535, followed by 'g'. s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); } + +/// A test handler that accumulates APC bytes regardless of whether they +/// arrive per-byte (apc_put) or in bulk (apc_put_slice). +const ApcTestHandler = struct { + buf: [256]u8 = undefined, + len: usize = 0, + slices: usize = 0, + puts: usize = 0, + started: usize = 0, + ended: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .apc_start => self.started += 1, + .apc_end => self.ended += 1, + .apc_put => { + self.buf[self.len] = value; + self.len += 1; + self.puts += 1; + }, + .apc_put_slice => { + @memcpy(self.buf[self.len..][0..value.bytes.len], value.bytes); + self.len += value.bytes.len; + self.slices += 1; + }, + else => {}, + } + } +}; + +test "stream: apc bulk slice" { + var s: Stream(ApcTestHandler) = .init(.{}); + s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24,s=10,v=20;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); + + // With SIMD enabled the body must arrive as a single slice. + if (comptime build_options.simd and !debug) { + try testing.expectEqual(@as(usize, 1), s.handler.slices); + try testing.expectEqual(@as(usize, 0), s.handler.puts); + } +} + +test "stream: apc bulk slice split across inputs" { + var s: Stream(ApcTestHandler) = .init(.{}); + s.nextSlice("\x1b_Gf=24,s=10"); + s.nextSlice(",v=20;aGVs"); + s.nextSlice("bG8=\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24,s=10,v=20;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc bulk slice keeps C0 bytes as data" { + var s: Stream(ApcTestHandler) = .init(.{}); + // BEL does not terminate an APC string; it is payload data. + s.nextSlice("\x1b_Gx\x07y\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings("Gx\x07y", s.handler.buf[0..s.handler.len]); +} + +test "stream: apc aborted by CAN" { + var s: Stream(ApcTestHandler) = .init(.{}); + // CAN (0x18) aborts the APC string via the anywhere => ground + // transition. Exiting the sos_pm_apc_string state emits apc_end, + // and the trailing bytes are printed, not treated as APC data. + s.nextSlice("\x1b_Gabcdefghijklmnopqrstuvwxyz0123456789\x18def"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gabcdefghijklmnopqrstuvwxyz0123456789", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc scalar path matches" { + var s: Stream(ApcTestHandler) = .init(.{}); + for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc vector boundaries match scalar path" { + const positions = [_]usize{ 15, 16, 17, 31, 32, 33, 63, 64, 65 }; + const controls = [_]u8{ 0x18, 0x1A, 0x1B, 0x80, 0xFF }; + + for (positions) |position| for (controls) |control| { + var input: [96]u8 = undefined; + input[0..3].* = "\x1b_G".*; + @memset(input[3 .. 3 + position], 'a'); + input[3 + position] = control; + input[4 + position] = '\\'; + const bytes = input[0 .. 5 + position]; + + var bulk: Stream(ApcTestHandler) = .init(.{}); + bulk.nextSlice(bytes); + var scalar: Stream(ApcTestHandler) = .init(.{}); + for (bytes) |byte| scalar.next(byte); + + try testing.expectEqual(scalar.handler.started, bulk.handler.started); + try testing.expectEqual(scalar.handler.ended, bulk.handler.ended); + try testing.expectEqualStrings( + scalar.handler.buf[0..scalar.handler.len], + bulk.handler.buf[0..bulk.handler.len], + ); + }; +} diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig index f68f088b..91cfb21c 100644 --- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig +++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig @@ -2,13 +2,16 @@ const std = @import("std"); const build_options = @import("terminal_options"); const testing = std.testing; const apc = @import("apc.zig"); +const clipboard = @import("clipboard.zig"); const csi = @import("csi.zig"); const device_attributes = @import("device_attributes.zig"); const device_status = @import("device_status.zig"); const stream = @import("stream.zig"); const Action = stream.Action; const Screen = @import("Screen.zig"); +const color = @import("color.zig"); const modes = @import("modes.zig"); +const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); @@ -43,6 +46,11 @@ pub const Handler = struct { /// the kitty graphics protocol. apc_handler: apc.Handler = .{}, + /// Default cursor style used by DECSCUSR reset (CSI 0 q). + default_cursor: bool = true, + default_cursor_style: Screen.CursorStyle = .block, + default_cursor_blink: bool = false, + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -78,6 +86,25 @@ pub const Handler = struct { /// handler.terminal.getTitle(). title_changed: ?*const fn (*Handler) void, + /// Called when the terminal pwd changes via escape sequences + /// (e.g. OSC 7). The new pwd can be queried via + /// handler.terminal.getPwd(). + pwd_changed: ?*const fn (*Handler) void, + + /// Called when the running program writes to a clipboard. The write + /// has a normalized destination and one or more decoded MIME + /// representations. All request, MIME, and data memory is borrowed + /// and only valid for the duration of the callback. + /// + /// A write with no contents clears the destination. A content entry + /// with empty data is a distinct empty representation. + /// + /// Clipboard read requests (OSC 52 with a "?" payload) are never + /// forwarded: answering one would let any program running in the + /// terminal silently read the user's clipboard, and a VT state + /// library has no way to mediate that with user consent. + clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult, + /// Called in response to an XTVERSION query. Returns the version /// string to report (e.g. "ghostty 1.2.3"). The returned memory /// must be valid for the lifetime of the call. The maximum length @@ -89,11 +116,13 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, + .clipboard_write = null, .color_scheme = null, .device_attributes = null, .enquiry = null, .size = null, .title_changed = null, + .pwd_changed = null, .write_pty = null, .xtversion = null, }; @@ -126,6 +155,7 @@ pub const Handler = struct { ) !void { switch (action) { .print => try self.terminal.print(value.cp), + .print_slice => try self.terminal.printSlice(value.cps), .print_repeat => try self.terminal.printRepeat(value), .backspace => self.terminal.backspace(), .carriage_return => self.terminal.carriageReturn(), @@ -152,12 +182,19 @@ pub const Handler = struct { self.terminal.screens.active.cursor.x + 1, ), .cursor_style => { + self.default_cursor = false; + const blink = switch (value) { - .default, .steady_block, .steady_bar, .steady_underline => false, + .default => self.default_cursor_blink, + .steady_block, .steady_bar, .steady_underline => false, .blinking_block, .blinking_bar, .blinking_underline => true, }; const style: Screen.CursorStyle = switch (value) { - .default, .blinking_block, .steady_block => .block, + .default => style: { + self.default_cursor = true; + break :style self.default_cursor_style; + }, + .blinking_block, .steady_block => .block, .blinking_bar, .steady_bar => .bar, .blinking_underline, .steady_underline => .underline, }; @@ -228,17 +265,23 @@ pub const Handler = struct { }, .active_status_display => self.terminal.status_display = value, .decaln => try self.terminal.decaln(), - .full_reset => self.terminal.fullReset(), + .full_reset => { + self.terminal.fullReset(); + self.default_cursor = true; + self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink); + self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style; + }, .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, - .color_operation => try self.colorOperation(value.op, &value.requests), + .color_operation => try self.colorOperation(&value.requests, value.terminator), .kitty_color_report => try self.kittyColorOperation(value), // APC .apc_start => self.apc_handler.start(), .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), + .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes), .apc_end => self.apcEnd(), // Effect-based handlers @@ -251,7 +294,9 @@ pub const Handler = struct { .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), .size_report => self.reportSize(value), .window_title => self.windowTitle(value.title), + .report_pwd => self.reportPwd(value.url), .xtversion => self.reportXtversion(), + .clipboard_contents => try self.clipboardContents(value.kind, value.data), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -261,10 +306,8 @@ pub const Handler = struct { => {}, // Have no terminal-modifying effect - .report_pwd, .show_desktop_notification, .progress_report, - .clipboard_contents, .title_push, .title_pop, => {}, @@ -281,6 +324,44 @@ pub const Handler = struct { func(self); } + fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { + const func = self.effects.clipboard_write orelse return; + + // Read requests are deliberately not forwarded; see the effect docs. + if (data.len == 1 and data[0] == '?') return; + + const location: clipboard.Location = switch (kind) { + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // OSC 52 uses an empty payload to clear the selected clipboard. + if (data.len == 0) { + _ = func(self, .{ + .location = location, + .contents = &.{}, + }); + return; + } + + const decoder = std.base64.standard.Decoder; + const decoded_len = try decoder.calcSizeForSlice(data); + const alloc = self.terminal.gpa(); + const decoded = try alloc.alloc(u8, decoded_len); + defer alloc.free(decoded); + try decoder.decode(decoded, data); + + const contents = [_]clipboard.Content{.{ + .mime = "text/plain", + .data = decoded, + }}; + _ = func(self, .{ + .location = location, + .contents = &contents, + }); + } + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -325,10 +406,11 @@ pub const Handler = struct { .color_scheme => { const func = self.effects.color_scheme orelse return; const scheme = func(self) orelse return; - self.writePty(switch (scheme) { - .dark => "\x1B[?997;1n", - .light => "\x1B[?997;2n", - }); + var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]); + device_status.encodeColorSchemeReport(&writer, scheme) catch return; + buf[writer.end] = 0; + self.writePty(buf[0..writer.end :0]); }, } } @@ -420,6 +502,29 @@ pub const Handler = struct { func(self); } + fn reportPwd(self: *Handler, url_raw: []const u8) void { + // Prevent DoS attacks by limiting url length. Headroom for + // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding. + const max_url_len = 4096; + const url = if (url_raw.len > max_url_len) url: { + log.warn("pwd url length {d} exceeds max length {d}, truncating", .{ + url_raw.len, + max_url_len, + }); + break :url url_raw[0..max_url_len]; + } else url_raw; + + // We store the raw payload unparsed. Embedders read it via + // getPwd() and are responsible for decoding any URI scheme. + self.terminal.setPwd(url) catch |err| { + log.warn("error setting pwd err={}", .{err}); + return; + }; + + const func = self.effects.pwd_changed orelse return; + func(self); + } + fn requestMode(self: *Handler, mode: modes.Mode) void { const report = self.terminal.modes.getReport(.fromMode(mode)); self.sendModeReport(report); @@ -550,12 +655,17 @@ pub const Handler = struct { fn colorOperation( self: *Handler, - op: osc_color.Operation, requests: *const osc_color.List, + terminator: osc.Terminator, ) !void { - _ = op; if (requests.count() == 0) return; + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + var it = requests.constIterator(0); while (it.next()) |req| { switch (req.*) { @@ -613,17 +723,67 @@ pub const Handler = struct { mask.* = .initEmpty(); }, - .query, - .reset_special, - => {}, + .query => |target| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForXterm(target) orelse continue; + try writeXtermColorReport(writer, target, c, terminator); + }, + + .reset_special => {}, } } + + if (response.written().len > 0) { + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } + } + + fn writeXtermColorReport( + writer: *std.Io.Writer, + target: osc_color.Target, + c: color.RGB, + terminator: osc.Terminator, + ) !void { + switch (target) { + .palette => |i| { + try writer.print("\x1b]4;{d};", .{i}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground, + .background, + .cursor, + => { + try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + } } fn kittyColorOperation( self: *Handler, request: kitty_color.OSC, ) !void { + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + for (request.list.items) |item| { switch (item) { .set => |v| switch (v.key) { @@ -650,9 +810,28 @@ pub const Handler = struct { else => {}, }, }, - .query => {}, + .query => |key| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForKitty(key) orelse { + if (!key.hasTerminalQueryColor()) continue; + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + continue; + }; + + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + try c.encodeRgb8(writer); + }, } } + + if (response.written().len > 0) { + try writer.writeAll(request.terminator.string()); + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } } fn apcEnd(self: *Handler) void { @@ -679,6 +858,25 @@ pub const Handler = struct { if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); } }, + + .glyph => |*glyph_req| { + const resp = self.terminal.glyphProtocol(alloc, glyph_req); + if (resp) |r| resp_block: { + // Don't waste time encoding if we can't write responses + // anyways. + if (self.effects.write_pty == null) break :resp_block; + + // Glyph responses are short and bounded by the protocol + // fields we emit, so this matches the Kitty response + // buffer size above with ample headroom. + var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + r.formatWire(&writer) catch return; + writer.writeByte(0) catch return; + const final = writer.buffered(); + self.writePty(final[0 .. final.len - 1 :0]); + } + }, } } }; @@ -900,6 +1098,8 @@ test "full reset" { s.nextSlice("\x1B[10;20H"); s.nextSlice("\x1B[5;20r"); // Set scroll region s.nextSlice("\x1B[?7l"); // Disable wraparound + s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); // Full reset s.nextSlice("\x1Bc"); @@ -910,6 +1110,35 @@ test "full reset" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.glyph_glossary.contains(0xE0A0)); +} + +test "glyph protocol APC with write_pty callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (last_response) |old| testing.allocator.free(old); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer if (S.last_response) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B_25a1;s\x1B\\"); + try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?); + + s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"); + try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); } test "ignores query actions" { @@ -923,6 +1152,8 @@ test "ignores query actions" { s.nextSlice("\x1B[c"); // Device attributes s.nextSlice("\x1B[5n"); // Device status report s.nextSlice("\x1B[6n"); // Cursor position report + s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query + s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query // Terminal should still be functional s.nextSlice("Test"); @@ -1039,6 +1270,63 @@ test "OSC 12 set and reset cursor color" { // After reset, cursor might be null (using default) } +test "OSC color query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]10;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]11;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]4;2;rgb:1212/3434/5656\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\"); + s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\"); + s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\"); + s.nextSlice("\x1b]10;?;?;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]10;rgb:0101/0202/0303\x1b\\" ++ + "\x1b]11;rgb:0404/0505/0606\x1b\\" ++ + "\x1b]12;rgb:0707/0808/0909\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]112\x1b\\"); + s.nextSlice("\x1b]12;?\x07"); + try testing.expectEqualStrings( + "\x1b]12;rgb:0101/0202/0303\x07", + S.last_response.?, + ); +} + test "kitty color protocol set palette" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1133,6 +1421,46 @@ test "kitty color protocol reset foreground" { try testing.expect(t.colors.foreground.get() == null); } +test "kitty color protocol query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]21;background=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;background=\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\"); + s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\", + S.last_response.?, + ); +} + test "palette dirty flag set on color change" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1321,6 +1649,155 @@ test "bell effect callback" { } } +test "clipboard_write effect callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores writes. + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + + // Terminal should still be functional after the ignored sequence + s.nextSlice("AfterClipboard"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterClipboard", str); + } + + t.fullReset(); + + const S = struct { + var count: usize = 0; + var result: clipboard.WriteResult = .success; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mime: ?[]u8 = null; + var last_data: ?[]u8 = null; + + fn clearCapture() void { + if (last_mime) |value| testing.allocator.free(value); + if (last_data) |value| testing.allocator.free(value); + last_mime = null; + last_data = null; + last_contents_len = 0; + } + + fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + clearCapture(); + count += 1; + last_location = write.location; + last_contents_len = write.contents.len; + if (write.contents.len > 0) { + last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch + @panic("failed to capture clipboard MIME type"); + last_data = testing.allocator.dupe(u8, write.contents[0].data) catch + @panic("failed to capture clipboard data"); + } + return result; + } + }; + S.count = 0; + S.result = .denied; + S.clearCapture(); + defer S.clearCapture(); + + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Selectors are normalized and payloads are decoded before the callback. + const cases = [_]struct { + sequence: []const u8, + location: clipboard.Location, + data: []const u8, + }{ + .{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" }, + .{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" }, + .{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" }, + .{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" }, + .{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" }, + .{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" }, + }; + + for (cases, 1..) |case, expected_count| { + s.nextSlice(case.sequence); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(case.location, S.last_location); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.last_mime.?); + try testing.expectEqualSlices(u8, case.data, S.last_data.?); + } + + // Empty data is a clear, represented by an empty contents slice. + s.nextSlice("\x1B]52;s;\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 1), S.count); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + try testing.expect(S.last_mime == null); + try testing.expect(S.last_data == null); + + // Reads and malformed base64 are ignored. + s.nextSlice("\x1B]52;c;?\x1B\\"); + s.nextSlice("\x1B]52;c;***\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 1), S.count); + + // OSC 1337 Copy shares the normalized clipboard write path. + s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 2), S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqualStrings("text/plain", S.last_mime.?); + try testing.expectEqualStrings("iTerm2", S.last_data.?); + + // Parsing across write boundaries still invokes exactly one atomic write. + s.nextSlice("\x1B]52;p;ZnJh"); + s.nextSlice("Z21lbnRlZA==\x1B"); + s.nextSlice("\\"); + try testing.expectEqual(@as(usize, cases.len + 3), S.count); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings("text/plain", S.last_mime.?); + try testing.expectEqualStrings("fragmented", S.last_data.?); + + // Callback results are intentionally ignored for protocols without a + // write acknowledgement. The denied result above did not stop later writes. + try testing.expectEqual(clipboard.WriteResult.denied, S.result); +} + +test "clipboard_write allocation failure is ignored" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var count: usize = 0; + + fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult { + count += 1; + return .success; + } + }; + S.count = 0; + + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Only the decoded scratch data uses the terminal allocator here. Swap in + // an allocator that always fails, then restore it before terminal teardown. + { + const alloc = t.screens.active.alloc; + t.screens.active.alloc = testing.failing_allocator; + defer t.screens.active.alloc = alloc; + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + } + try testing.expectEqual(@as(usize, 0), S.count); +} + test "request mode DECRQM with write_pty callback" { var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); diff --git a/vendor/libghostty-vt/src/terminal/style.zig b/vendor/libghostty-vt/src/terminal/style.zig index ae9488af..720789bb 100644 --- a/vendor/libghostty-vt/src/terminal/style.zig +++ b/vendor/libghostty-vt/src/terminal/style.zig @@ -966,6 +966,36 @@ test "Set capacities" { _ = Set.Layout.init(16384); } +test "Set zero capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + // A zero-capacity set is a valid special case (see Layout.init). + // It occurs in practice for pages with exact capacities where no + // cell is styled (see Page.exactRowCapacity). + const layout: Set.Layout = .init(0); + try testing.expectEqual(0, layout.total_size); + + // We allocate a nonzero buffer filled with 0xFF to simulate the + // set being embedded in a larger structure (e.g. a Page) where + // other data follows it. Lookups must not probe the zero-size + // table: table[0] would read this adjacent memory and treat it + // as an item ID, leading to far out-of-bounds item reads. + const buf = try alloc.alignedAlloc(u8, Set.base_align, 64); + defer alloc.free(buf); + @memset(buf, 0xFF); + + var set = Set.init(.init(buf), layout, .{}); + + const style: Style = .{ .flags = .{ .bold = true } }; + try testing.expectEqual(null, set.lookup(buf, style)); + try testing.expectError(error.OutOfMemory, set.add(buf, style)); + try testing.expectEqual(0, set.count()); + + // The adjacent memory must be untouched. + for (buf) |b| try testing.expectEqual(0xFF, b); +} + test "Style HTML formatting basic bold" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/x11_color.zig b/vendor/libghostty-vt/src/terminal/x11_color.zig index 97f3a7ce..7239a47d 100644 --- a/vendor/libghostty-vt/src/terminal/x11_color.zig +++ b/vendor/libghostty-vt/src/terminal/x11_color.zig @@ -2,6 +2,20 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const RGB = @import("color.zig").RGB; +/// A single X11 color entry. +pub const Entry = struct { + /// Color name. Null-terminated so it can be exposed through the C API + /// without runtime allocation. + name: [:0]const u8, + color: RGB, +}; + +/// All X11 colors in rgb.txt file order. +/// +/// This is kept separate from `map` because the C API needs stable file order +/// and null-terminated names. `ColorMap` keys do not provide that contract. +pub const entries: []const Entry = entriesArray(); + /// The map of all available X11 colors. pub const map = colorMap(); @@ -10,15 +24,10 @@ pub const ColorMap = std.StaticStringMapWithEql( std.static_string_map.eqlAsciiIgnoreCase, ); -fn colorMap() ColorMap { - @setEvalBranchQuota(500_000); - - const KV = struct { []const u8, RGB }; - - // The length of our data is the number of lines in the rgb file. +fn entriesArray() []const Entry { + @setEvalBranchQuota(1_000_000); const len = std.mem.count(u8, data, "\n"); - var kvs: [len]KV = undefined; - + var result: [len]Entry = undefined; // Parse the line. This is not very robust parsing, because we expect // a very exact format for rgb.txt. However, this is all done at comptime // so if our data is bad, we should hopefully get an error here or one @@ -37,11 +46,29 @@ fn colorMap() ColorMap { const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10); const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10); const name = std.mem.trim(u8, line[12..], " \t"); - kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } }; + var name_z: [name.len:0]u8 = undefined; + @memcpy(name_z[0..name.len], name); + name_z[name.len] = 0; + const final_name = name_z; + result[i] = .{ + .name = final_name[0..name.len :0], + .color = .{ .r = r, .g = g, .b = b }, + }; i += 1; } assert(i == len); + const final = result; + return &final; +} + +fn colorMap() ColorMap { + @setEvalBranchQuota(1_000_000); + + const KV = struct { []const u8, RGB }; + var kvs: [entries.len]KV = undefined; + for (entries, 0..) |entry, i| kvs[i] = .{ entry.name, entry.color }; + return .initComptime(kvs); } @@ -67,3 +94,14 @@ test { try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen")); try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen")); } + +test "entries" { + const testing = std.testing; + + try testing.expect(entries.len > 700); + for (entries) |entry| { + try testing.expectEqual(entry.color, map.get(entry.name).?); + try testing.expectEqual(@as(u8, 0), entry.name.ptr[entry.name.len]); + } + try testing.expectEqualStrings("snow", entries[0].name); +} diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig index 87d47807..0894f87b 100644 --- a/vendor/libghostty-vt/src/termio/Exec.zig +++ b/vendor/libghostty-vt/src/termio/Exec.zig @@ -1240,23 +1240,149 @@ const Subprocess = struct { } }; -/// The read thread sits in a loop doing the following pseudo code: +/// The read thread works with a companion gather thread to form a two-stage +/// pipeline that moves pty output into the terminal: +/// +/// io-gather: read()/poll() the pty into one of a few rotating +/// buffers, batching bulk output. +/// io-reader: hand each filled buffer to processOutput (terminal +/// lock, VT parse, state update, render scheduling). +/// +/// This used to be a single serial loop (and still is on Windows): /// /// while (true) { blocking_read(); exit_if_eof(); process(); } /// -/// Almost all terminal-modifying activity is from the pty read, so -/// putting this on a dedicated thread keeps performance very predictable -/// while also almost optimal. "Locking is fast, lock contention is slow." -/// and since we rarely have contention, this is fast. +/// I found on macOS that the kernel tty output queue caps every read +/// on the master at 1 KB no matter how large the read buffer is. This means +/// that producers (e.g. `cat`) stall with the above architecture because +/// there are windows in the `process()` part where we aren't draining +/// the kernel pty fd. +/// +/// Instead, having a separate thread gather and drain the kernel pty +/// into a rotating set of preallocated buffers minimizes this stall +/// period to effectively zero: while the io-reader thread parses +/// one batch, the gather thread is draining the kernel queue. There is +/// still stalling (our VT parse is a bottleneck now), but we don't stall +/// between them. /// -/// This is also empirically fast compared to putting the read into -/// an async mechanism like io_uring/epoll because the reads are generally -/// small. +/// Interactive latency is preserved: a batch is delivered on the +/// first EAGAIN unless the stream is saturated (>= 1 KiB gathered +/// means the writer filled the kernel queue), in which case we bridge +/// the producer's microsecond refill gaps with a short poll, bounded +/// by a small total budget per batch that is well under a display +/// frame. This means that small outputs that are more typical continue +/// to be interactive. /// -/// We use a basic poll syscall here because we are only monitoring two -/// fds and this is still much faster and lower overhead than any async -/// mechanism. +/// We use basic poll/read syscalls here because we are only +/// monitoring two fds and this is still much faster and lower +/// overhead than any async mechanism. pub const ReadThread = struct { + /// The number of buffers rotated between the gather and parse + /// stages. The gather stage can run at most this many batches + /// ahead of the parse stage before it blocks, which (via the + /// kernel pty queue) is also what preserves flow control to the + /// child. Empirically chosen through measurements on an M4 Max. + /// Less than 4 there are minor slowdowns, above 4 there are no + /// improvements. + const buffer_count = 4; + + /// The capacity of each gather buffer. One batch is also the unit + /// of work the parse stage does per terminal lock acquisition, so + /// this bounds both gather latency and lock hold time. + const buffer_capacity = 64 * 1024; + + /// How many gathered bytes mark a stream as saturated. The macOS + /// kernel tty output queue hands the master at most about 1 KiB + /// per read, so gathering a full 1 KiB means the writer filled + /// the queue (a bulk stream worth briefly waiting on), while + /// anything smaller is an interactive trickle that must be + /// delivered with no added latency. + const bridge_threshold = 1024; + + /// How many times an EAGAIN on a saturated stream is retried + /// with an immediate read before we're willing to sleep in poll. + /// Basically, a spin retry. + /// + /// The writer refills the drained kernel queue within a few + /// microseconds, while a sleep and wakeup through poll costs + /// several more. If the gather stage sleeps on every refill gap, + /// the whole pipeline degenerates to lockstep with the writer at + /// about 1 KiB per wakeup. A short burst of nonblocking reads + /// bridges nearly all refill gaps without sleeping. Measured, 8 + /// to 16 retries catches over 90% of the gaps and nearly doubles + /// the saturated drain rate, and larger values helped little. + /// The cost is bounded to at most this many extra ~0.5us read + /// syscalls per gap, and we only spin on streams that already + /// gathered a full kernel queue, so an idle or interactive + /// terminal never spins. + const bridge_spin_max = 16; + + /// How long one bridge poll waits for the writer's next refill + /// once the spin retries above have failed. If the stream is + /// quiet for this long the burst is over and we deliver what we + /// have. + const bridge_poll_timeout_ms = 1; + + /// The longest one batch may spend bridging refill gaps before it + /// is delivered regardless. This bounds output latency for + /// streams that produce just enough to keep bridging. Three + /// milliseconds is well under one display frame, so batching is + /// invisible on screen. + const gather_budget_ns = 3 * std.time.ns_per_ms; + + /// The state shared between the gather and parse stages. This is + /// a fixed ring of buffers plus the metadata to rotate ownership + /// between the two threads. A buffer is owned by exactly one + /// stage at a time, so buffer contents need no locking. Only the + /// ring metadata is guarded by the mutex. + const Pipeline = struct { + mutex: std.Thread.Mutex = .{}, + + /// Signaled when a batch is published or the gather stage is + /// done. Waited on by the parse stage. + batch_ready: std.Thread.Condition = .{}, + + /// Signaled when a batch has been consumed. Waited on by the + /// gather stage when all buffers are in flight (backpressure). + slot_free: std.Thread.Condition = .{}, + + /// The number of valid bytes in each buffer. Set at publish + /// time by the gather stage, read by the parse stage. + lens: [buffer_count]usize = @splat(0), + + /// Ring state: head is the next slot the gather stage fills, + /// tail is the next slot the parse stage consumes, count is + /// the number of published, unconsumed batches. + head: usize = 0, + tail: usize = 0, + count: usize = 0, + + /// Set by the gather stage (under the mutex) while it sleeps + /// in a bridge poll. The parse stage only writes to the idle + /// pipe when this is set, so an interactive terminal never + /// pays the pipe syscalls. + bridging: bool = false, + + /// A self-pipe the parse stage uses to interrupt the gather + /// stage's bridge poll the moment it runs out of batches. + /// Bridging a refill gap is only free while the parse stage + /// is busy; once it goes idle, every additional microsecond + /// spent bridging is added straight to output latency. The + /// write end is used by the parse stage, the read end is + /// polled by the gather stage. -1 when unavailable, in which + /// case bridge polls are bounded by their timeout only. + idle_read_fd: posix.fd_t = -1, + idle_write_fd: posix.fd_t = -1, + + /// Set by the gather stage when the stream is over (quit + /// signal, EOF, or pty error). The parse stage drains any + /// remaining batches and then exits. + done: bool = false, + + /// The buffer storage itself. + bufs: [buffer_count][buffer_capacity]u8 = undefined, + }; + fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1266,6 +1392,7 @@ pub const ReadThread = struct { // so instead we use this code to name the thread instead. if (builtin.os.tag.isDarwin()) { internal_os.macos.pthread_setname_np(&"io-reader".*); + setQosClass(); } // Setup our crash metadata @@ -1275,71 +1402,292 @@ pub const ReadThread = struct { }; defer crash.sentry.thread_state = null; - // First thing, we want to set the fd to non-blocking. We do this - // so that we can try to read from the fd in a tight loop and only - // check the quit fd occasionally. - if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { - _ = posix.fcntl( - fd, - posix.F.SETFL, - flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - }; + // Set the fd to non-blocking so the gather stage can drain it + // in a tight loop and fall back to poll for readiness. The + // pipeline can't run with a blocking fd (a blocking read + // would hang the gather stage on a quiet pty), but this also + // can't realistically fail on a valid pty master. + if (!setNonblock(fd)) { + log.err("read thread exiting, pty fd must be non-blocking", .{}); + return; + } + + // Shared pipeline + var pipeline: Pipeline = .{}; + + // The idle self-pipe (see the Pipeline field docs). If we + // can't create it we still run correctly, bridge polls are + // just bounded by their timeout instead of being interrupted + // when the parse stage goes idle. + if (posix.pipe2(.{ + .CLOEXEC = true, + .NONBLOCK = true, + })) |fds| { + pipeline.idle_read_fd = fds[0]; + pipeline.idle_write_fd = fds[1]; } else |err| { - log.warn("read thread failed to get flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); + log.warn("read thread failed to create idle pipe err={}", .{err}); } + defer if (pipeline.idle_read_fd >= 0) { + posix.close(pipeline.idle_read_fd); + posix.close(pipeline.idle_write_fd); + }; - // Build up the list of fds we're going to poll. We are looking - // for data on the pty and our quit notification. - var pollfds: [2]posix.pollfd = .{ + const gather_thread = std.Thread.spawn( + .{}, + gatherMainPosix, + .{ fd, quit, &pipeline }, + ) catch |err| { + // If we can't spawn a thread the process is already + // doomed (every surface spawns several), so don't try + // to limp along. + log.err("read thread exiting, failed to spawn gather thread err={}", .{err}); + return; + }; + defer gather_thread.join(); + if (comptime !builtin.os.tag.isDarwin()) { + gather_thread.setName("io-gather") catch {}; + } + + // This thread is the parse stage. We consume batches in ring + // order until the gather stage reports the stream is over and + // the ring is drained. + while (true) { + const batch: []const u8 = batch: { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + while (pipeline.count == 0) { + if (pipeline.done) return; + pipeline.batch_ready.wait(&pipeline.mutex); + } + const slot = pipeline.tail; + break :batch pipeline.bufs[slot][0..pipeline.lens[slot]]; + }; + + // The batch buffer is owned by this stage until we advance + // the tail below, so it is safe to read outside the lock. + io.processOutput(batch); + + { + pipeline.mutex.lock(); + pipeline.tail = (pipeline.tail + 1) % buffer_count; + pipeline.count -= 1; + const wake = pipeline.count == 0 and + pipeline.bridging and + pipeline.idle_write_fd >= 0; + pipeline.mutex.unlock(); + pipeline.slot_free.signal(); + + // We ran out of batches while the gather stage is + // bridging a refill gap: interrupt its poll so it + // delivers what it has instead of sleeping out the + // timeout while we sit idle. + if (wake) { + _ = posix.write(pipeline.idle_write_fd, "i") catch {}; + } + } + + // Batch boundary: hand the renderer state mutex off if + // the renderer is waiting. See renderer.State.lockDemand. + io.renderer_state.yieldToDemand(); + } + } + + /// The gather stage. This drains the pty into rotating buffers, + /// bridging the kernel queue's refill gaps for saturated streams, + /// and publishes each batch to the parse stage. This thread owns + /// all fd monitoring, including the quit fd. + fn gatherMainPosix(fd: posix.fd_t, quit: posix.fd_t, pipeline: *Pipeline) void { + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"io-gather".*); + setQosClass(); + } + + // However we exit, tell the parse stage the stream is over so + // it drains the ring and joins us. + defer { + pipeline.mutex.lock(); + pipeline.done = true; + pipeline.mutex.unlock(); + pipeline.batch_ready.signal(); + } + + // The fds we poll: data on the pty, our quit notification, + // and the parse stage's idle wake. The idle fd only + // participates in bridge polls (the parse stage only writes + // while we're bridging), so the outer poll slices it off. + var pollfds: [3]posix.pollfd = .{ .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, + .{ .fd = pipeline.idle_read_fd, .events = posix.POLL.IN, .revents = undefined }, }; - var buf: [1024]u8 = undefined; while (true) { - // We try to read from the file descriptor as long as possible - // to maximize performance. We only check the quit fd if the - // main fd blocks. This optimizes for the realistic scenario that - // the data will eventually stop while we're trying to quit. This - // is always true because we kill the process. - while (true) { - const n = posix.read(fd, &buf) catch |err| { - switch (err) { - // This means our pty is closed. We're probably - // gracefully shutting down. - error.NotOpenForReading, - error.InputOutput, - => { - log.info("io reader exiting", .{}); - return; - }, + // Claim the next free buffer. This blocks only when the + // parse stage is a full ring behind, which is exactly when + // we should stop reading and let the kernel queue exert + // backpressure on the child. + const buf: *[buffer_capacity]u8 = buf: { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + while (pipeline.count == buffer_count) { + pipeline.slot_free.wait(&pipeline.mutex); + } + break :buf &pipeline.bufs[pipeline.head]; + }; - // No more data, fall back to poll and check for - // exit conditions. - error.WouldBlock => break, + var total: usize = 0; + var bridge_start: ?std.time.Instant = null; + var spins: usize = 0; + var fatal = false; + + // Fill the buffer from the pty. For a saturated stream the + // kernel queue momentarily runs dry while the writer + // refills it in parallel, so we bridge those gaps with + // spin retries and a short poll instead of delivering a + // tiny batch. + gather: while (total < buffer_capacity) { + const n = posix.read( + fd, + buf[total..], + ) catch |err| switch (err) { + error.WouldBlock => { + // Anything below the threshold is interactive. + if (total < bridge_threshold) break :gather; + + // The stream is saturated, so we bridge the + // gap. First retry the read directly a bounded + // number of times, since the refill usually + // lands within microseconds. + if (spins < bridge_spin_max) { + spins += 1; + continue :gather; + } - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } + // Still dry, so we want to sleep in poll for + // the next refill, within our latency budget. + const now = std.time.Instant.now() catch + break :gather; + if (bridge_start) |start| { + if (now.since(start) >= gather_budget_ns) + break :gather; + } else bridge_start = now; + + // Bridging a refill gap is only free while the + // parse stage is busy, since the wait hides + // behind parse time. Once the parser is idle, + // every microsecond we hold this batch is + // added straight to output latency, and for a + // request/response producer (a burst ending + // in a query, e.g. frame + cursor position + // report) the writer is blocked on a reply to + // data sitting in this buffer, so a poll here + // would always sleep its full timeout. Deliver + // now if the parser is idle, and otherwise arm + // the idle wake so it can interrupt our poll + // the moment that changes. + { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + if (pipeline.count == 0) break :gather; + pipeline.bridging = true; + } + + const r = posix.poll( + &pollfds, + bridge_poll_timeout_ms, + ) catch |poll_err| { + clearBridging(pipeline); + log.warn("bridge poll failed err={}", .{poll_err}); + break :gather; + }; + clearBridging(pipeline); + + // Quiet for a full timeout means the burst + // ended. + if (r == 0) break :gather; + + // On a quit signal we deliver what we have + // and stop. + if (pollfds[1].revents & posix.POLL.IN != 0) { + log.info("read thread got quit signal", .{}); + fatal = true; + break :gather; + } + + // The parse stage went idle: drain the wake + // and deliver what we have. The pty may have + // data as well, but the next batch can pick + // that up; an idle parser means delivery must + // not wait. + if (pollfds[2].revents & posix.POLL.IN != 0) { + var trash: [16]u8 = undefined; + while (true) { + const drained = posix.read( + pipeline.idle_read_fd, + &trash, + ) catch break; + if (drained < trash.len) break; + } + break :gather; + } + + // HUP without IN means no more data is + // coming. Deliver and let the outer poll + // decide what to do. + if (pollfds[0].revents & posix.POLL.IN == 0) + break :gather; + + continue :gather; + }, + + // The pty is closed. We're probably gracefully + // shutting down. + error.NotOpenForReading, + error.InputOutput, + => { + log.info("io gather exiting", .{}); + fatal = true; + break :gather; + }, + + else => { + log.err("io gather error err={}", .{err}); + unreachable; + }, }; // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; + // child process dies. Deliver what we have and let the + // outer poll detect HUP. + if (n == 0) break :gather; - // log.info("DATA: {d}", .{n}); - @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); + total += n; + + // Each refill gap gets a fresh spin budget. + spins = 0; + } + + // Publish the batch (if any) to the parse stage and rotate + // to the next buffer. + if (total > 0) { + pipeline.mutex.lock(); + pipeline.lens[pipeline.head] = total; + pipeline.head = (pipeline.head + 1) % buffer_count; + pipeline.count += 1; + pipeline.mutex.unlock(); + pipeline.batch_ready.signal(); } - // Wait for data. - _ = posix.poll(&pollfds, -1) catch |err| { + if (fatal) return; + + // A full buffer means the stream is still hot, so go + // claim the next buffer without an intervening poll. + if (total == buffer_capacity) continue; + + // Wait for data. The idle fd is sliced off: the parse + // stage only writes to it while we're bridging. + _ = posix.poll(pollfds[0..2], -1) catch |err| { log.warn("poll failed on read thread, exiting early err={}", .{err}); return; }; @@ -1359,6 +1707,50 @@ pub const ReadThread = struct { } } + /// Clears the bridging flag armed before a bridge poll, closing + /// the window in which the parse stage writes idle wakes. + fn clearBridging(pipeline: *Pipeline) void { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + pipeline.bridging = false; + } + + /// Sets the QoS class of the calling thread for the read pipeline + /// (macOS only). Both pipeline threads feed content the user is + /// actively watching, and at default QoS the scheduler may place + /// them on efficiency cores with wakeup latencies that are large + /// compared to the ~10us cadence of the pty producer/consumer + /// dance. Measured on an M4 Max, this results in a 15% throughput + /// difference (on the change, not 15% total). + fn setQosClass() void { + internal_os.macos.setQosClass(.user_initiated) catch |err| { + log.warn("error setting QoS class err={}", .{err}); + }; + } + + /// Sets the fd to non-blocking mode. Returns false on failure. + fn setNonblock(fd: posix.fd_t) bool { + const flags = posix.fcntl( + fd, + posix.F.GETFL, + 0, + ) catch |err| { + log.warn("read thread failed to get flags err={}", .{err}); + return false; + }; + + _ = posix.fcntl( + fd, + posix.F.SETFL, + flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), + ) catch |err| { + log.warn("read thread failed to set flags err={}", .{err}); + return false; + }; + + return true; + } + fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1388,6 +1780,11 @@ pub const ReadThread = struct { } @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); + + // See threadMainPosix: hand the renderer state mutex + // off if the renderer is waiting, since this loop + // would otherwise starve it under heavy output. + io.renderer_state.yieldToDemand(); } var quit_bytes: windows.DWORD = 0; diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig index 1d1bfe25..1b08c5e9 100644 --- a/vendor/libghostty-vt/src/termio/Termio.zig +++ b/vendor/libghostty-vt/src/termio/Termio.zig @@ -712,11 +712,15 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } - const output = switch (self.config.conditional_state.theme) { - .light => "\x1B[?997;2n", - .dark => "\x1B[?997;1n", + const scheme: terminalpkg.device_status.ColorScheme = switch (self.config.conditional_state.theme) { + .light => .light, + .dark => .dark, }; - try self.queueWrite(td, output, false); + + var buf: [terminalpkg.device_status.max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.device_status.encodeColorSchemeReport(&writer, scheme); + try self.queueWrite(td, writer.buffered(), false); } /// ThreadData is the data created and stored in the termio thread diff --git a/vendor/libghostty-vt/src/termio/stream_handler.zig b/vendor/libghostty-vt/src/termio/stream_handler.zig index fb3a6b3f..43c5d611 100644 --- a/vendor/libghostty-vt/src/termio/stream_handler.zig +++ b/vendor/libghostty-vt/src/termio/stream_handler.zig @@ -205,6 +205,10 @@ pub const StreamHandler = struct { @branchHint(.likely); try self.terminal.print(value.cp); }, + .print_slice => { + @branchHint(.likely); + try self.terminal.printSlice(value.cps); + }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), @@ -357,6 +361,7 @@ pub const StreamHandler = struct { .apc_start => self.apc.start(), .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), + .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), // Unimplemented .title_push, @@ -559,6 +564,23 @@ pub const StreamHandler = struct { } } }, + + .glyph => |*glyph_req| { + const resp = self.terminal.glyphProtocol(self.alloc, glyph_req); + switch (glyph_req.*) { + .register, .clear => try self.queueRender(), + .support, .query => {}, + } + + if (resp) |r| { + var buf: [terminal.apc.glyph.Response.max_wire_bytes]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try r.formatWire(&writer); + const final = writer.buffered(); + log.debug("glyph protocol response: {x}", .{final}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + }, } } diff --git a/vendor/libghostty-vt/src/unicode/grapheme.zig b/vendor/libghostty-vt/src/unicode/grapheme.zig index d233dec7..73782dd6 100644 --- a/vendor/libghostty-vt/src/unicode/grapheme.zig +++ b/vendor/libghostty-vt/src/unicode/grapheme.zig @@ -2,6 +2,31 @@ const std = @import("std"); const table = @import("props_table.zig").table; const uucode = @import("uucode"); +/// Width change requested by a codepoint that continues a grapheme cluster. +pub const GraphemeWidthEffect = enum { + /// Do not append the codepoint to the cluster and leave break state as it + /// was before seeing it. + ignore, + + /// Append the codepoint but leave the current cluster width unchanged. + no_change, + + /// Make the cluster occupy two terminal cells. + wide, + + /// Make the cluster occupy one terminal cell. + narrow, +}; + +/// Result of measuring the first grapheme cluster in a codepoint slice. +pub const GraphemeWidth = struct { + /// Number of codepoints consumed from the input slice. + len: usize, + + /// Display width in terminal cells. + width: u2, +}; + /// Determines if there is a grapheme break between two codepoints. This /// must be called sequentially maintaining the state between calls. /// @@ -21,6 +46,99 @@ pub fn graphemeBreak(cp1: u21, cp2: u21, state: *uucode.grapheme.BreakState) boo return value.result; } +/// Returns the width effect of appending cp after prev within a grapheme. +/// +/// This is the shared width-decision kernel for the streaming terminal +/// printer and for graphemeWidth. It assumes graphemeBreak has already said +/// there is no break between prev and cp; it does not perform segmentation. +/// +/// The .ignore result is important for invalid emoji variation selectors. The +/// terminal does not store those selectors in the cell, so callers must also +/// restore their grapheme break state and leave prev unchanged when they see +/// .ignore. +pub inline fn graphemeWidthEffect(prev: u21, cp: u21) GraphemeWidthEffect { + // Emoji variation selectors modify the width of a valid base: + // VS16 makes the grapheme wide and VS15 makes it narrow. Check that + // prev forms a valid variation sequence in emoji-variation-sequences.txt; + // if it does not, ignore the selector entirely. + if (cp == 0xFE0F or cp == 0xFE0E) { + const prev_props = table.get(prev); + if (!prev_props.emoji_vs_base) return .ignore; + + return switch (cp) { + 0xFE0F => .wide, + 0xFE0E => .narrow, + else => unreachable, + }; + } + + // If a code point contributes to the width of a grapheme, the whole + // grapheme is at least width 2 because the first code point must be at + // least width 1 to start. Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle that yet. + if (!table.get(cp).width_zero_in_grapheme) return .wide; + + return .no_change; +} + +/// Measures the first grapheme cluster in cps using the same segmentation and +/// width rules as Terminal.print with mode 2027. +/// +/// This is not a streaming API: cps must contain a complete first grapheme +/// cluster or the logical end of the string. If bytes/codepoints arrive in +/// chunks, keep buffering when this consumes all available codepoints and more +/// input may still arrive. +/// +/// For codepoint types wider than u21, values greater than U+10FFFF are +/// accepted so FFI-facing callers can use u32 input without trapping. An +/// invalid value consumes one codepoint at width 1 when it starts the slice, +/// and terminates the current cluster when it appears later. For u21 callers +/// these checks are comptime-dead. +pub fn graphemeWidth(comptime T: type, cps: []const T) GraphemeWidth { + const check_invalid = comptime @bitSizeOf(T) > @bitSizeOf(u21); + + if (cps.len == 0) return .{ .len = 0, .width = 0 }; + + // The C API accepts u32 codepoints, so it can receive values outside + // Unicode's range. Guard before narrowing to u21; native u21 callers + // skip this path at comptime. + if (check_invalid and invalidCodepoint(cps[0])) return .{ .len = 1, .width = 1 }; + + var len: usize = 1; + var width = table.get(@as(u21, @intCast(cps[0]))).width; + var prev: u21 = @intCast(cps[0]); + var state: uucode.grapheme.BreakState = .default; + + while (len < cps.len) : (len += 1) { + // Treat invalid u32 input as a boundary so a valid prefix cluster can + // still be returned without attempting to narrow the invalid value. + if (check_invalid and invalidCodepoint(cps[len])) break; + + const cp: u21 = @intCast(cps[len]); + const state_before = state; + if (graphemeBreak(prev, cp, &state)) break; + + switch (graphemeWidthEffect(prev, cp)) { + .ignore => state = state_before, + .no_change => prev = cp, + .wide => { + width = 2; + prev = cp; + }, + .narrow => { + width = 1; + prev = cp; + }, + } + } + + return .{ .len = len, .width = width }; +} + +inline fn invalidCodepoint(cp: anytype) bool { + return cp > 0x10FFFF; +} + /// This is all the structures and data for the precomputed lookup table /// for all possible permutations of state and grapheme break properties. /// Precomputation requires 2^13 keys of 4 bit values so the whole table is @@ -178,3 +296,70 @@ test "long emoji zwj sequences" { try std.testing.expect(cp1 == 0x1F466); // 👦 try std.testing.expect(graphemeBreak(cp1, cp2, &state)); // break } + +test "grapheme width: variation selectors" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidthEffect.wide, graphemeWidthEffect(0x2764, 0xFE0F)); + try testing.expectEqual(GraphemeWidthEffect.narrow, graphemeWidthEffect(0x23, 0xFE0E)); + try testing.expectEqual(GraphemeWidthEffect.ignore, graphemeWidthEffect('x', 0xFE0F)); + + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x2764, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x23, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 'x', 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 1 }, graphemeWidth(u21, &.{ 'x', 0xFE0F, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 0x23, 0xFE0E })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 0x231A, 0xFE0E })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 1 }, graphemeWidth(u21, &.{ 0x231A, 0xFE0E, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 4, .width = 2 }, graphemeWidth(u21, &.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F })); +} + +test "grapheme width: emoji sequences" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 5, .width = 2 }, graphemeWidth(u21, &.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 2 }, graphemeWidth(u21, &.{ 0x23, 0xFE0F, 0x20E3 })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ '1', 0x20E3 })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x1F44B, 0x1F3FF })); +} + +test "grapheme width: spacing marks can widen narrow clusters" { + const testing = std.testing; + + var mark: ?u21 = null; + for (0..0x110000) |cp_usize| { + const cp: u21 = @intCast(cp_usize); + const props = table.get(cp); + if (props.width != 1 or props.width_zero_in_grapheme) continue; + + var state: uucode.grapheme.BreakState = .default; + if (!graphemeBreak('a', cp, &state)) { + mark = cp; + break; + } + } + + try testing.expect(mark != null); + const cp = mark.?; + try testing.expectEqual(@as(u2, 1), table.get(cp).width); + try testing.expect(!table.get(cp).width_zero_in_grapheme); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 'a', cp })); +} + +test "grapheme width: segmentation" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u21, &.{'a'})); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u21, &.{ 'a', 'b' })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 })); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 2 }, graphemeWidth(u21, &.{0x1F1E8})); + try testing.expectEqual(GraphemeWidth{ .len = 0, .width = 0 }, graphemeWidth(u21, &.{})); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 0 }, graphemeWidth(u21, &.{ 0x0301, 0x0302 })); +} + +test "grapheme width: u32 invalid codepoints stand alone" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u32, &.{ 0x110000, 0x0301 })); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u32, &.{ 'a', 0x110000 })); +} diff --git a/vendor/libghostty-vt/src/unicode/main.zig b/vendor/libghostty-vt/src/unicode/main.zig index 11ecbd90..fb18b7d1 100644 --- a/vendor/libghostty-vt/src/unicode/main.zig +++ b/vendor/libghostty-vt/src/unicode/main.zig @@ -3,7 +3,60 @@ pub const lut = @import("lut.zig"); const grapheme = @import("grapheme.zig"); pub const table = @import("props_table.zig").table; pub const Properties = @import("props.zig").Properties; +pub const GraphemeWidthEffect = grapheme.GraphemeWidthEffect; +pub const GraphemeWidth = grapheme.GraphemeWidth; pub const graphemeBreak = grapheme.graphemeBreak; +pub const graphemeWidth = grapheme.graphemeWidth; +pub const graphemeWidthEffect = grapheme.graphemeWidthEffect; + +/// Returns the terminal display width of a codepoint in terminal +/// grid cells: 0, 1, or 2. +/// +/// This is the same width table the terminal uses when laying out +/// printed text: 0 for zero-width codepoints (controls, combining +/// marks, default-ignorables, surrogates), 2 for wide codepoints +/// (East Asian Wide/Fullwidth, regional indicators, clamped at 2), +/// and 1 otherwise. +/// +/// This operates on a single codepoint and cannot account for +/// grapheme-cluster-level width rules (VS16, combining sequences); +/// callers needing cluster-accurate widths should use graphemeWidth(). +/// Summing per-codepoint widths is only correct when mode 2027 is +/// disabled. +pub fn codepointWidth(cp: u21) u2 { + return table.get(cp).width; +} + +test "codepointWidth" { + const testing = @import("std").testing; + + // Narrow (width 1) + try testing.expectEqual(1, codepointWidth('a')); + try testing.expectEqual(1, codepointWidth(' ')); + try testing.expectEqual(1, codepointWidth(0x10FFFF)); // max codepoint + + // C0/C1 control characters (width 0) + try testing.expectEqual(0, codepointWidth(0x00)); // NUL + try testing.expectEqual(0, codepointWidth(0x07)); // BEL + try testing.expectEqual(0, codepointWidth(0x1B)); // ESC + try testing.expectEqual(0, codepointWidth(0x7F)); // DEL + try testing.expectEqual(0, codepointWidth(0x80)); // C1 PAD + + // Zero-width codepoints + try testing.expectEqual(0, codepointWidth(0x0301)); // combining acute + try testing.expectEqual(0, codepointWidth(0x200B)); // zero width space + try testing.expectEqual(0, codepointWidth(0x200D)); // ZWJ + try testing.expectEqual(0, codepointWidth(0xFE0F)); // VS16 + try testing.expectEqual(0, codepointWidth(0xD800)); // surrogate + + // Wide (width 2) + try testing.expectEqual(2, codepointWidth(0x4E00)); // CJK ideograph + try testing.expectEqual(2, codepointWidth(0xFF21)); // fullwidth A + try testing.expectEqual(2, codepointWidth(0xAC00)); // Hangul syllable + try testing.expectEqual(2, codepointWidth(0x1F600)); // emoji + try testing.expectEqual(2, codepointWidth(0x1F1E6)); // regional indicator + try testing.expectEqual(2, codepointWidth(0x2E3B)); // three-em dash (clamped) +} test { @import("std").testing.refAllDecls(@This()); diff --git a/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch b/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch deleted file mode 100644 index 1f8d0f36..00000000 --- a/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch +++ /dev/null @@ -1,115 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: herdr maintainers <maintainers@herdr.dev> -Date: Thu, 4 Jun 2026 00:00:00 +0000 -Subject: [PATCH] backport resizeCols cursor subtraction saturation - -Backport ghostty-org/ghostty#12907 onto the vendored libghostty-vt source. -Shrink-both resize can leave the old cursor row past the new row count after -resizeWithoutReflow reduces self.rows. Saturating subtraction treats rows below -that cursor as zero instead of trapping in safety builds. - -Herdr issue: https://github.com/ogulcancelik/herdr/issues/465 -Upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905 -Upstream PR: https://github.com/ghostty-org/ghostty/pull/12907 -Vendored base: 0f7cd84b880b203c98683e520e84b9db0c5938d8 ---- - vendor/libghostty-vt/src/terminal/PageList.zig | 33 +++++++++++++++++++++-- - vendor/libghostty-vt/src/terminal/c/terminal.zig | 23 ++++++++++++++++ - 2 files changed, 54 insertions(+), 2 deletions(-) - -diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig -index 0000000..0000000 100644 ---- a/vendor/libghostty-vt/src/terminal/PageList.zig -+++ b/vendor/libghostty-vt/src/terminal/PageList.zig -@@ -1059,7 +1059,7 @@ fn resizeCols( - break :cursor .{ - .tracked_pin = c.pin orelse try self.trackPin(p), - .untrack = c.pin == null, -- .remaining_rows = self.rows - c.y - 1, -+ .remaining_rows = self.rows -| (c.y + 1), - .wrapped_rows = wrapped, - }; - } else null; -@@ -1192,7 +1192,7 @@ fn resizeCols( - break :wrapped wrapped; - }; - -- const current = self.rows - active_pt.active.y - 1; -+ const current = self.rows -| (active_pt.active.y + 1); - - var req_rows = c.remaining_rows; - req_rows -|= wrapped -| c.wrapped_rows; -@@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" { - } - } - -+test "PageList resize less rows and cols cursor at bottom" { -+ const testing = std.testing; -+ const alloc = testing.allocator; -+ -+ var s = try init(alloc, 80, 24, 0); -+ defer s.deinit(); -+ -+ const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ -+ .x = 0, -+ .y = s.rows - 1, -+ } }).?); -+ defer s.untrackPin(cursor_pin); -+ -+ // Shrink both axes so the original cursor.y is strictly past the new row -+ // count, after resizeWithoutReflow has already reduced self.rows. -+ try s.resize(.{ -+ .cols = 79, -+ .rows = 20, -+ .reflow = true, -+ .cursor = .{ .x = 0, .y = 23, .pin = cursor_pin }, -+ }); -+ try testing.expectEqual(@as(usize, 79), s.cols); -+ try testing.expectEqual(@as(usize, 20), s.rows); -+ -+ // remaining_rows saturates to 0, so the cursor lands on the new bottom row. -+ try testing.expectEqual(point.Point{ .active = .{ -+ .x = 0, -+ .y = s.rows - 1, -+ } }, s.pointFromPin(.active, cursor_pin.*).?); -+} -+ - test "PageList resize (no reflow) more rows and less cols" { - const testing = std.testing; - const alloc = testing.allocator; -diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig -index 0000000..0000000 100644 ---- a/vendor/libghostty-vt/src/terminal/c/terminal.zig -+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig -@@ -1005,6 +1005,29 @@ test "resize invalid value" { - try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18)); - } - -+test "resize shrinks both axes with cursor at bottom" { -+ var t: Terminal = null; -+ try testing.expectEqual(Result.success, new( -+ &lib.alloc.test_allocator, -+ &t, -+ .{ -+ .cols = 80, -+ .rows = 24, -+ .max_scrollback = 0, -+ }, -+ )); -+ defer free(t); -+ -+ // CSI 24;1H parks the cursor on the bottom row. -+ const move = "\x1b[24;1H"; -+ vt_write(t, move, move.len); -+ -+ // Shrink both axes; pre-resize cursor.y sits past the new bottom row. -+ try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16)); -+ try testing.expectEqual(79, t.?.terminal.cols); -+ try testing.expectEqual(23, t.?.terminal.rows); -+} -+ - test "mode_get and mode_set" { - var t: Terminal = null; - try testing.expectEqual(Result.success, new( --- -2.49.0 diff --git a/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch b/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch new file mode 100644 index 00000000..546112c7 --- /dev/null +++ b/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch @@ -0,0 +1,22 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers <maintainers@herdr.dev> +Date: Wed, 15 Jul 2026 00:00:00 +0000 +Subject: [PATCH] default lib-vt panes to grapheme clustering + +Herdr renders libghostty-vt cells directly and requires DEC mode 2027 to +store multi-codepoint grapheme clusters in one terminal cell. Set it in the +terminal's default modes so RIS restores clustering instead of disabling it. + +Herdr issue: https://github.com/ogulcancelik/herdr/issues/243 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 1c3b9f0c..a896c0fd 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -344,4 +344,5 @@ fn new_( + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, ++ .default_modes = .{ .grapheme_cluster = true }, + }); + errdefer t.deinit(alloc); diff --git a/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch b/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch deleted file mode 100644 index 4289740c..00000000 --- a/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch +++ /dev/null @@ -1,112 +0,0 @@ -diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -index 9bace3a..d5b5006 100644 ---- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -+++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -@@ -343,6 +343,15 @@ typedef enum GHOSTTY_ENUM_TYPED { - */ - GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, - -+ /** -+ * Timestamp of the image's most recent transmission, in nanoseconds. -+ * The epoch is unspecified; only equality and ordering between values -+ * read from the same terminal are meaningful. -+ * -+ * Output type: uint64_t * -+ */ -+ GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9, -+ - GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, - } GhosttyKittyGraphicsImageData; - -diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -index d50b8c4..a00632e 100644 ---- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -@@ -178,6 +178,7 @@ pub const ImageData = enum(c_int) { - compression = 6, - data_ptr = 7, - data_len = 8, -+ transmit_time_ns = 9, - - pub fn OutType(comptime self: ImageData) type { - return switch (self) { -@@ -187,6 +188,7 @@ pub const ImageData = enum(c_int) { - .compression => ImageCompression, - .data_ptr => [*]const u8, - .data_len => usize, -+ .transmit_time_ns => u64, - }; - } - }; -@@ -258,11 +260,22 @@ fn imageGetTyped( - .compression => out.* = image.compression, - .data_ptr => out.* = image.data.ptr, - .data_len => out.* = image.data.len, -+ .transmit_time_ns => out.* = instantNanos(image.transmit_time), - } - - return .success; - } - -+/// Flattens an Instant's platform timestamp to nanoseconds for the C API. -+/// The epoch is unspecified; only equality and ordering are meaningful. -+fn instantNanos(instant: std.time.Instant) u64 { -+ if (@TypeOf(instant.timestamp) == u64) return instant.timestamp; -+ const ts = instant.timestamp; -+ const sec: u64 = @intCast(@max(ts.sec, 0)); -+ const nsec: u64 = @intCast(@max(ts.nsec, 0)); -+ return sec *| std.time.ns_per_s +| nsec; -+} -+ - pub fn placement_iterator_new( - alloc_: ?*const CAllocator, - out: *PlacementIterator, -@@ -977,6 +990,48 @@ test "image_get_handle and image_get with transmitted image" { - try testing.expect(data_len > 0); - } - -+test "image_get transmit_time_ns changes on retransmission" { -+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest; -+ -+ var t: terminal_c.Terminal = null; -+ try testing.expectEqual(Result.success, terminal_c.new( -+ &lib.alloc.test_allocator, -+ &t, -+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, -+ )); -+ defer terminal_c.free(t); -+ -+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; -+ terminal_c.vt_write(t, cmd.ptr, cmd.len); -+ -+ var graphics: KittyGraphics = undefined; -+ try testing.expectEqual(Result.success, terminal_c.get( -+ t, -+ .kitty_graphics, -+ @ptrCast(&graphics), -+ )); -+ -+ var first: u64 = undefined; -+ try testing.expectEqual(Result.success, image_get( -+ image_get_handle(graphics, 1), -+ .transmit_time_ns, -+ @ptrCast(&first), -+ )); -+ try testing.expect(first > 0); -+ -+ std.Thread.sleep(1 * std.time.ns_per_ms); -+ const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; -+ terminal_c.vt_write(t, retransmit.ptr, retransmit.len); -+ -+ var second: u64 = undefined; -+ try testing.expectEqual(Result.success, image_get( -+ image_get_handle(graphics, 1), -+ .transmit_time_ns, -+ @ptrCast(&second), -+ )); -+ try testing.expect(second > first); -+} -+ - test "placement_rect with transmit and display" { - if (comptime !build_options.kitty_graphics) return error.SkipZigTest; - |
