mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 07:08:39 +00:00
libghostty-vt: add C API for snapshotting functions (#13580)
Expose terminal snapshot through the libghostty-vt C API and add a new C
example that runs in CI to verify this stuff works!
## Example
```c
// Enable PTY continuation tracking
size_t continuation_limit = 1024;
assert(ghostty_terminal_set(
terminal,
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
&continuation_limit) == GHOSTTY_SUCCESS);
// Encode a terminal with heap allocation
uint8_t *bytes = NULL;
size_t len = 0;
assert(ghostty_snapshot_encode_alloc(
terminal, NULL, &bytes, &len) == GHOSTTY_SUCCESS);
// Full blocking decode from an owned buffer.
GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new_buf(
NULL, &decoder, bytes, len) == GHOSTTY_SUCCESS);
GhosttyTerminal restored = NULL;
assert(ghostty_snapshot_decoder_decode(
decoder, &restored) == GHOSTTY_SUCCESS);
ghostty_snapshot_decoder_free(decoder);
ghostty_free(NULL, bytes, len);
```
Streaming decode:
```c
// Streaming decoder from a custom reader IO function.
GhosttyReader reader = {
.read = read_snapshot,
.userdata = source,
};
GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new(
NULL, &decoder, reader) == GHOSTTY_SUCCESS);
// Read up to the ready state (when we can render and start processing pty bytes)
GhosttyTerminal terminal = NULL;
assert(ghostty_snapshot_decoder_ready(
decoder, &terminal) == GHOSTTY_SUCCESS);
// Sometime later or async process remaining frames.
GhosttyResult result;
while ((result = ghostty_snapshot_decoder_next(decoder)) ==
GHOSTTY_SUCCESS) {
size_t rows = 0;
assert(ghostty_snapshot_decoder_get(
decoder,
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
&rows) == GHOSTTY_SUCCESS);
render(terminal);
}
assert(result == GHOSTTY_NO_VALUE);
```
This commit is contained in:
15
example/c-vt-snapshot/README.md
Normal file
15
example/c-vt-snapshot/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Example: Terminal Snapshots in C
|
||||
|
||||
This example creates a terminal with continuation tracking, encodes its full
|
||||
state, and restores the snapshot using both the one-shot and incremental C
|
||||
decoder APIs. The incremental path uses a synchronous `GhosttyReader` callback
|
||||
and reports each restored history page. The standalone project links the static
|
||||
libghostty-vt artifact so it can run consistently on every supported host.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the example:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
33
example/c-vt-snapshot/build.zig
Normal file
33
example/c-vt-snapshot/build.zig
Normal file
@@ -0,0 +1,33 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addCSourceFiles(.{
|
||||
.root = b.path("src"),
|
||||
.files = &.{"main.c"},
|
||||
});
|
||||
exe_mod.addCMacro("GHOSTTY_STATIC", "");
|
||||
|
||||
if (b.lazyDependency("ghostty", .{})) |dep| {
|
||||
exe_mod.linkLibrary(dep.artifact("ghostty-vt-static"));
|
||||
}
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "c_vt_snapshot",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
14
example/c-vt-snapshot/build.zig.zon
Normal file
14
example/c-vt-snapshot/build.zig.zon
Normal file
@@ -0,0 +1,14 @@
|
||||
.{
|
||||
.name = .c_vt_snapshot,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xff13ccc637fd5383,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
.ghostty = .{ .path = "../../" },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
162
example/c-vt-snapshot/src/main.c
Normal file
162
example/c-vt-snapshot/src/main.c
Normal file
@@ -0,0 +1,162 @@
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [snapshot-buffer-reader]
|
||||
typedef struct {
|
||||
const uint8_t *data;
|
||||
size_t len;
|
||||
size_t offset;
|
||||
} BufferReader;
|
||||
|
||||
// GhosttyReader callbacks are synchronous. A successful zero-byte read is
|
||||
// permanent EOF; returning false would report an I/O error.
|
||||
static bool buffer_read(void *userdata,
|
||||
uint8_t *buffer,
|
||||
size_t capacity,
|
||||
size_t *out_read) {
|
||||
BufferReader *reader = userdata;
|
||||
size_t remaining = reader->len - reader->offset;
|
||||
size_t count = remaining < capacity ? remaining : capacity;
|
||||
|
||||
// Deliberately return short reads to demonstrate that the decoder retries.
|
||||
if (count > 64) count = 64;
|
||||
memcpy(buffer, reader->data + reader->offset, count);
|
||||
reader->offset += count;
|
||||
*out_read = count;
|
||||
return true;
|
||||
}
|
||||
//! [snapshot-buffer-reader]
|
||||
|
||||
int main(void) {
|
||||
GhosttyResult result;
|
||||
|
||||
//! [snapshot-encode]
|
||||
GhosttyTerminal source = NULL;
|
||||
// A wide, shallow screen fills backing pages quickly enough to leave older
|
||||
// PAGE records after READY for the incremental decoder to demonstrate.
|
||||
result = ghostty_terminal_new(NULL, &source, 215, 2);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// Snapshot encoding requires continuation tracking to be enabled before
|
||||
// feeding input. The limit bounds unfinished VT sequence retention.
|
||||
const size_t continuation_limit = 1024;
|
||||
result = ghostty_terminal_set(
|
||||
source,
|
||||
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
|
||||
&continuation_limit);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// Keep enough scrollback to demonstrate incremental history restoration.
|
||||
result = ghostty_terminal_set(
|
||||
source, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, NULL);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
const char *line = "snapshot history line\r\n";
|
||||
for (size_t i = 0; i < 1000; i++) {
|
||||
ghostty_terminal_vt_write(
|
||||
source, (const uint8_t *)line, strlen(line));
|
||||
}
|
||||
|
||||
// Leave an SGR sequence unfinished so its continuation is snapshotted too.
|
||||
const char *unfinished = "\x1b[31";
|
||||
ghostty_terminal_vt_write(
|
||||
source, (const uint8_t *)unfinished, strlen(unfinished));
|
||||
|
||||
uint8_t *snapshot = NULL;
|
||||
size_t snapshot_len = 0;
|
||||
result = ghostty_snapshot_encode_alloc(
|
||||
source, NULL, &snapshot, &snapshot_len);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
printf("encoded %zu snapshot bytes\n", snapshot_len);
|
||||
//! [snapshot-encode]
|
||||
|
||||
//! [snapshot-decode]
|
||||
GhosttySnapshotDecoder full_decoder = NULL;
|
||||
result = ghostty_snapshot_decoder_new_buf(
|
||||
NULL, &full_decoder, snapshot, snapshot_len);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttyTerminal full_terminal = NULL;
|
||||
result = ghostty_snapshot_decoder_decode(full_decoder, &full_terminal);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
ghostty_snapshot_decoder_free(full_decoder);
|
||||
ghostty_terminal_free(full_terminal);
|
||||
//! [snapshot-decode]
|
||||
|
||||
//! [snapshot-incremental]
|
||||
BufferReader reader_state = {
|
||||
.data = snapshot,
|
||||
.len = snapshot_len,
|
||||
.offset = 0,
|
||||
};
|
||||
GhosttyReader reader = {
|
||||
.read = buffer_read,
|
||||
.userdata = &reader_state,
|
||||
};
|
||||
|
||||
GhosttySnapshotDecoder incremental_decoder = NULL;
|
||||
result = ghostty_snapshot_decoder_new(
|
||||
NULL, &incremental_decoder, reader);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// READY authenticates and returns a renderable terminal before old history.
|
||||
GhosttyTerminal incremental_terminal = NULL;
|
||||
result = ghostty_snapshot_decoder_ready(
|
||||
incremental_decoder, &incremental_terminal);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
uint64_t history_rows = 0;
|
||||
result = ghostty_snapshot_decoder_get(
|
||||
incremental_decoder,
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY,
|
||||
&history_rows);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
printf("snapshot advertises %llu primary history rows\n",
|
||||
(unsigned long long)history_rows);
|
||||
|
||||
size_t page_count = 0;
|
||||
while ((result = ghostty_snapshot_decoder_next(incremental_decoder)) ==
|
||||
GHOSTTY_SUCCESS) {
|
||||
GhosttyTerminalScreen screen;
|
||||
size_t rows = 0;
|
||||
uint32_t remaining = 0;
|
||||
const GhosttySnapshotDecoderData keys[] = {
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN,
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING,
|
||||
};
|
||||
void *values[] = {&screen, &rows, &remaining};
|
||||
size_t written = 0;
|
||||
result = ghostty_snapshot_decoder_get_multi(
|
||||
incremental_decoder,
|
||||
sizeof(keys) / sizeof(keys[0]),
|
||||
keys,
|
||||
values,
|
||||
&written);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
assert(written == sizeof(keys) / sizeof(keys[0]));
|
||||
|
||||
printf("restored %zu rows to screen %d (%u pages remain)\n",
|
||||
rows, (int)screen, remaining);
|
||||
page_count++;
|
||||
}
|
||||
|
||||
// NO_VALUE means FINISH authenticated successfully and is idempotent.
|
||||
assert(result == GHOSTTY_NO_VALUE);
|
||||
assert(page_count > 0);
|
||||
assert(ghostty_snapshot_decoder_next(incremental_decoder) ==
|
||||
GHOSTTY_NO_VALUE);
|
||||
|
||||
ghostty_snapshot_decoder_free(incremental_decoder);
|
||||
ghostty_terminal_free(incremental_terminal);
|
||||
//! [snapshot-incremental]
|
||||
|
||||
ghostty_free(NULL, snapshot, snapshot_len);
|
||||
ghostty_terminal_free(source);
|
||||
return 0;
|
||||
}
|
||||
@@ -31,12 +31,14 @@
|
||||
* - @ref terminal "Terminal" - Complete terminal emulator state and rendering
|
||||
* - @ref render "Render State" - Incremental render state updates for custom renderers
|
||||
* - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML
|
||||
* - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state
|
||||
* - @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 io "Byte-stream I/O" - Reusable synchronous reader and writer callbacks
|
||||
* - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
|
||||
*
|
||||
* Encoding related APIs:
|
||||
@@ -140,6 +142,7 @@ extern "C" {
|
||||
#include <ghostty/vt/terminal.h>
|
||||
#include <ghostty/vt/grid_ref.h>
|
||||
#include <ghostty/vt/grid_ref_tracked.h>
|
||||
#include <ghostty/vt/io.h>
|
||||
#include <ghostty/vt/osc.h>
|
||||
#include <ghostty/vt/sgr.h>
|
||||
#include <ghostty/vt/style.h>
|
||||
@@ -153,6 +156,7 @@ extern "C" {
|
||||
#include <ghostty/vt/screen.h>
|
||||
#include <ghostty/vt/selection.h>
|
||||
#include <ghostty/vt/size_report.h>
|
||||
#include <ghostty/vt/snapshot.h>
|
||||
#include <ghostty/vt/unicode.h>
|
||||
#include <ghostty/vt/wasm.h>
|
||||
|
||||
|
||||
107
include/ghostty/vt/io.h
Normal file
107
include/ghostty/vt/io.h
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file io.h
|
||||
*
|
||||
* Generic IO callbacks for libghostty-vt.
|
||||
*/
|
||||
|
||||
#ifndef GHOSTTY_VT_IO_H
|
||||
#define GHOSTTY_VT_IO_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/** @defgroup io I/O
|
||||
*
|
||||
* Synchronous callback interfaces used by APIs that consume or produce byte
|
||||
* streams. The callback and userdata pointers must remain valid for the full
|
||||
* lifetime documented by the API receiving a GhosttyReader or GhosttyWriter.
|
||||
*
|
||||
* @{
|
||||
*/
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Read bytes from a source.
|
||||
*
|
||||
* The callback must set @p out_read to a value no greater than @p capacity
|
||||
* when returning true. A positive value reports progress; it may be less than
|
||||
* capacity and does not indicate end-of-file. A zero value is definitive
|
||||
* end-of-file. It must not be used to report temporary input starvation or a
|
||||
* would-block condition.
|
||||
*
|
||||
* Returning false reports a fatal read error and the value of @p out_read is
|
||||
* ignored. The library does not inspect or modify errno.
|
||||
*
|
||||
* All pointer arguments are borrowed and valid only for the duration of the
|
||||
* callback. The callback is invoked synchronously on the calling thread.
|
||||
*
|
||||
* @param userdata Opaque userdata from GhosttyReader
|
||||
* @param buffer Destination for read bytes; always non-NULL
|
||||
* @param capacity Writable capacity of @p buffer; always greater than zero
|
||||
* @param[out] out_read Number of bytes read when returning true; non-NULL
|
||||
* @return true for a successful read or end-of-file, false for a fatal error
|
||||
*/
|
||||
typedef bool (*GhosttyReaderFn)(
|
||||
void* userdata,
|
||||
uint8_t* buffer,
|
||||
size_t capacity,
|
||||
size_t* out_read);
|
||||
|
||||
/**
|
||||
* Write bytes to a destination.
|
||||
*
|
||||
* Returning true means all @p len bytes were accepted. Returning false
|
||||
* reports a fatal write error. A callback wrapping an interface that permits
|
||||
* partial writes must retry internally until the full slice is accepted or
|
||||
* an error occurs.
|
||||
*
|
||||
* On failure, the destination may already contain a prefix of the bytes. The
|
||||
* calling operation fails and must not be resumed from that partial output.
|
||||
* The library does not inspect or modify errno.
|
||||
*
|
||||
* @p data is borrowed and valid only for the duration of the callback. The
|
||||
* callback is invoked synchronously on the calling thread. Successful return
|
||||
* means the bytes were handed to the destination; it does not imply that the
|
||||
* destination was flushed or made durable.
|
||||
*
|
||||
* @param userdata Opaque userdata from GhosttyWriter
|
||||
* @param data Source bytes; always non-NULL
|
||||
* @param len Number of source bytes; always greater than zero
|
||||
* @return true if the complete slice was accepted, false on fatal error
|
||||
*/
|
||||
typedef bool (*GhosttyWriterFn)(
|
||||
void* userdata,
|
||||
const uint8_t* data,
|
||||
size_t len);
|
||||
|
||||
/**
|
||||
* A byte source callback and its opaque context.
|
||||
*
|
||||
* The struct is passed by value. @p read must be non-NULL.
|
||||
*/
|
||||
typedef struct {
|
||||
GhosttyReaderFn read;
|
||||
void* userdata;
|
||||
} GhosttyReader;
|
||||
|
||||
/**
|
||||
* A byte destination callback and its opaque context.
|
||||
*
|
||||
* The struct is passed by value. @p write must be non-NULL.
|
||||
*/
|
||||
typedef struct {
|
||||
GhosttyWriterFn write;
|
||||
void* userdata;
|
||||
} GhosttyWriter;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
/** @} */
|
||||
|
||||
#endif /* GHOSTTY_VT_IO_H */
|
||||
525
include/ghostty/vt/snapshot.h
Normal file
525
include/ghostty/vt/snapshot.h
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* @file snapshot.h
|
||||
*
|
||||
* Encode and restore complete terminal snapshots.
|
||||
*/
|
||||
|
||||
#ifndef GHOSTTY_VT_SNAPSHOT_H
|
||||
#define GHOSTTY_VT_SNAPSHOT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <ghostty/vt/allocator.h>
|
||||
#include <ghostty/vt/io.h>
|
||||
#include <ghostty/vt/terminal.h>
|
||||
#include <ghostty/vt/types.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** @defgroup snapshot Terminal Snapshot
|
||||
*
|
||||
* Encode and restore the complete state of a terminal via a binary format.
|
||||
*
|
||||
* A snapshot is an ordered, authenticated record stream. Its READY checkpoint
|
||||
* contains enough state to render and resume the terminal, including any
|
||||
* unfinished VT parser input. Older scrollback pages follow READY and the
|
||||
* FINISH checkpoint authenticates the complete snapshot.
|
||||
*
|
||||
* End-of-file before an operation's required READY or FINISH checkpoint is
|
||||
* malformed, truncated snapshot data and returns GHOSTTY_INVALID_VALUE.
|
||||
* GHOSTTY_IO_ERROR is reserved for a reader callback that returns false.
|
||||
*
|
||||
* ## Examples
|
||||
*
|
||||
* The complete working example is available in `example/c-vt-snapshot`.
|
||||
*
|
||||
* ### Encode a terminal and its unfinished VT continuation
|
||||
* @snippet c-vt-snapshot/src/main.c snapshot-encode
|
||||
*
|
||||
* ### Restore a complete snapshot in one call
|
||||
* @snippet c-vt-snapshot/src/main.c snapshot-decode
|
||||
*
|
||||
* ### Adapt a byte source to GhosttyReader
|
||||
* @snippet c-vt-snapshot/src/main.c snapshot-buffer-reader
|
||||
*
|
||||
* ### Restore READY first, then incrementally prepend history
|
||||
* @snippet c-vt-snapshot/src/main.c snapshot-incremental
|
||||
*
|
||||
* ## Format
|
||||
*
|
||||
* Every integer is unsigned and little-endian. The stream begins with this
|
||||
* fixed ten-byte envelope:
|
||||
*
|
||||
* @code{.unparsed}
|
||||
* byte 0 8 10
|
||||
* +---------------+--------+
|
||||
* | "GHOSTSNP" | version|
|
||||
* | 8-byte magic | u16 |
|
||||
* +---------------+--------+
|
||||
* @endcode
|
||||
*
|
||||
* The envelope is followed by independently checksummed records. A record's
|
||||
* CRC32C covers its encoded tag and payload length followed by its payload; it
|
||||
* does not cover the CRC field itself.
|
||||
*
|
||||
* @code{.unparsed}
|
||||
* byte 0 2 6 10 10 + payload_len
|
||||
* +-------+-------------+-----------+----------------+
|
||||
* | tag | payload_len | CRC32C | payload |
|
||||
* | u16 | u32 | u32 | payload_len B |
|
||||
* +-------+-------------+-----------+----------------+
|
||||
* \____________________/ \______________/
|
||||
* CRC prefix CRC suffix
|
||||
* @endcode
|
||||
*
|
||||
* Record groups occur in this strict order. SCREEN and HISTORY groups contain
|
||||
* one entry for each screen declared by TERMINAL. Each manifest is followed
|
||||
* by the number of PAGE records it declares. Active SCREEN pages make the
|
||||
* terminal renderable; HISTORY pages are older scrollback ordered newest to
|
||||
* oldest so an incremental decoder can prepend them as they arrive.
|
||||
*
|
||||
* @code{.unparsed}
|
||||
* +---------------- TERMINAL ----------------+
|
||||
* | terminal-wide state and screen count |
|
||||
* +----------------- SCREEN -----------------+ repeated per screen
|
||||
* | active-screen manifest |
|
||||
* +------------------ PAGE ------------------+ repeated per manifest
|
||||
* | active screen rows |
|
||||
* +------------- CONTINUATION ---------------+
|
||||
* | unfinished VT/UTF-8 input, or ground |
|
||||
* +------------------ READY -----------------+
|
||||
* | BLAKE3-256 of every preceding byte | ready() returns here
|
||||
* +----------------- HISTORY ----------------+ repeated per screen
|
||||
* | scrollback manifest |
|
||||
* +------------------ PAGE ------------------+ next() consumes one page
|
||||
* | older screen rows |
|
||||
* +------------------ FINISH ----------------+
|
||||
* | BLAKE3-256 of every preceding byte | next() returns NO_VALUE
|
||||
* +------------------------------------------+
|
||||
* | trailing transport bytes (not consumed) |
|
||||
* +------------------------------------------+
|
||||
* @endcode
|
||||
*
|
||||
* READY authenticates the renderable prefix through CONTINUATION. FINISH
|
||||
* authenticates READY and every history record as well as the earlier prefix.
|
||||
* Thus record CRC32C detects local corruption while the BLAKE3 checkpoints
|
||||
* also bind the ordering and completeness of the record stream.
|
||||
*
|
||||
* Snapshot format version 1 is a work in progress and does not yet carry a
|
||||
* binary-compatibility guarantee.
|
||||
*
|
||||
* @see <a href="https://github.com/ghostty-org/ghostty/blob/main/src/terminal/snapshot/main.zig">Snapshot format and Zig codec documentation</a>
|
||||
*
|
||||
* @{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configurable snapshot decoder options.
|
||||
*
|
||||
* Options may only be changed before decoding starts. Calling
|
||||
* ghostty_snapshot_decoder_set() after ghostty_snapshot_decoder_ready() or
|
||||
* ghostty_snapshot_decoder_decode() returns GHOSTTY_INVALID_VALUE.
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/**
|
||||
* Largest non-ground continuation the decoder will accept.
|
||||
*
|
||||
* A value of zero accepts only snapshots whose VT parser is in the ground
|
||||
* state. The decoder default matches the largest built-in APC protocol
|
||||
* buffer limit, currently 65 MiB.
|
||||
*
|
||||
* This is an input validation limit only. It does not configure continuation
|
||||
* tracking on a terminal returned by the decoder.
|
||||
*
|
||||
* Input type: size_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0,
|
||||
|
||||
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttySnapshotDecoderOption;
|
||||
|
||||
/**
|
||||
* Queryable snapshot decoder data.
|
||||
*
|
||||
* Each variant documents the output pointer type expected by
|
||||
* ghostty_snapshot_decoder_get().
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** Invalid data type. Never results in data extraction. */
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_INVALID = 0,
|
||||
|
||||
/**
|
||||
* Current maximum accepted continuation size.
|
||||
*
|
||||
* This value is available in every non-failed decoder state.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_CONTINUATION_BYTES = 1,
|
||||
|
||||
/**
|
||||
* Number of snapshot source bytes consumed so far.
|
||||
*
|
||||
* At FINISH this identifies the first byte after the snapshot. Trailing
|
||||
* bytes are not consumed. This value is unavailable after a decoding error,
|
||||
* because the decoder can no longer guarantee its source position.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET = 2,
|
||||
|
||||
/**
|
||||
* Advisory complete logical history extent for the primary screen.
|
||||
*
|
||||
* The value counts rows before the active area, including any resident
|
||||
* overlap carried before READY. It becomes available after READY validates.
|
||||
*
|
||||
* Output type: uint64_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY = 3,
|
||||
|
||||
/**
|
||||
* Advisory complete logical history extent for the alternate screen.
|
||||
*
|
||||
* The value has the same semantics and lifetime as
|
||||
* GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY. Querying it returns
|
||||
* GHOSTTY_NO_VALUE when the snapshot does not declare an alternate screen.
|
||||
*
|
||||
* Output type: uint64_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_ALTERNATE = 4,
|
||||
|
||||
/**
|
||||
* Screen associated with the most recently decoded history page.
|
||||
*
|
||||
* This value is available only after ghostty_snapshot_decoder_next()
|
||||
* returns GHOSTTY_SUCCESS. A later call to next replaces it or clears it
|
||||
* when FINISH is reached or an error occurs.
|
||||
*
|
||||
* Output type: GhosttyTerminalScreen *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN = 5,
|
||||
|
||||
/**
|
||||
* Rows prepended by the most recently decoded history page.
|
||||
*
|
||||
* Zero means the page was consumed and authenticated but could not be
|
||||
* applied to the live terminal.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS = 6,
|
||||
|
||||
/**
|
||||
* Page records remaining in the same screen's HISTORY sequence.
|
||||
*
|
||||
* This is not a count of all pages remaining in the snapshot.
|
||||
*
|
||||
* Output type: uint32_t *
|
||||
*/
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7,
|
||||
|
||||
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttySnapshotDecoderData;
|
||||
|
||||
/**
|
||||
* Encode a complete terminal snapshot to a writer.
|
||||
*
|
||||
* The terminal's persistent VT stream supplies the continuation bytes needed
|
||||
* to reconstruct unfinished parser state. The caller must prevent concurrent
|
||||
* writes or other terminal mutation for the duration of this call. The writer
|
||||
* callback must not call terminal APIs with the same terminal handle.
|
||||
* A terminal can be encoded with tracking disabled when its VT parser and
|
||||
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
|
||||
* have been enabled before the input that produced that state was written;
|
||||
* otherwise this returns GHOSTTY_INVALID_VALUE.
|
||||
*
|
||||
* Encoding begins at the writer's current position. If an error occurs, the
|
||||
* writer may contain a partial snapshot without a valid FINISH checkpoint.
|
||||
* Calls to the writer are synchronous; this function does not flush or make
|
||||
* the caller's destination durable.
|
||||
*
|
||||
* @param terminal Terminal to encode (must not be NULL)
|
||||
* @param writer Destination writer whose write callback must not be NULL
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects
|
||||
* output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or
|
||||
* another error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_encode(GhosttyTerminal terminal,
|
||||
GhosttyWriter writer);
|
||||
|
||||
/**
|
||||
* Encode a complete terminal snapshot to a caller-provided buffer.
|
||||
*
|
||||
* Pass NULL for buf with buf_len zero to query the required size. If the
|
||||
* buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the
|
||||
* required capacity in out_written. A non-NULL undersized buffer may contain
|
||||
* a partial snapshot prefix. On success, out_written receives the number of
|
||||
* bytes encoded.
|
||||
*
|
||||
* A terminal can be encoded with tracking disabled when its VT parser and
|
||||
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
|
||||
* have been enabled before the input that produced that state was written;
|
||||
* otherwise this returns GHOSTTY_INVALID_VALUE.
|
||||
*
|
||||
* @param terminal Terminal to encode (must not be NULL)
|
||||
* @param buf Destination buffer, or NULL when buf_len is zero
|
||||
* @param buf_len Destination buffer capacity in bytes
|
||||
* @param[out] out_written Bytes written, or required capacity on
|
||||
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_encode_buf(
|
||||
GhosttyTerminal terminal,
|
||||
uint8_t* buf,
|
||||
size_t buf_len,
|
||||
size_t* out_written);
|
||||
|
||||
/**
|
||||
* Encode a complete terminal snapshot to an allocated buffer.
|
||||
*
|
||||
* The returned buffer is allocated with allocator, or the default allocator
|
||||
* when allocator is NULL. The caller must release it with ghostty_free(),
|
||||
* passing the same allocator used here.
|
||||
*
|
||||
* A terminal can be encoded with tracking disabled when its VT parser and
|
||||
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
|
||||
* have been enabled before the input that produced that state was written;
|
||||
* otherwise this returns GHOSTTY_INVALID_VALUE.
|
||||
*
|
||||
* @param terminal Terminal to encode (must not be NULL)
|
||||
* @param allocator Allocator for the output, or NULL for the default allocator
|
||||
* @param[out] out_ptr Allocated snapshot bytes (must not be NULL)
|
||||
* @param[out] out_len Number of allocated snapshot bytes (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_encode_alloc(
|
||||
GhosttyTerminal terminal,
|
||||
const GhosttyAllocator* allocator,
|
||||
uint8_t** out_ptr,
|
||||
size_t* out_len);
|
||||
|
||||
/**
|
||||
* Create a snapshot decoder that reads from a caller-provided reader.
|
||||
*
|
||||
* The decoder stores a copy of reader. Its read callback must not be NULL, and
|
||||
* both the callback and its caller-owned context must remain valid until
|
||||
* FINISH is reached or the decoder is freed. Reads are synchronous and occur
|
||||
* only during ready, next, or decode calls. A zero-byte successful read is
|
||||
* permanent end-of-file, not temporary starvation; nonblocking sources must
|
||||
* wait outside the decoder or block in their callback. The read callback must
|
||||
* not call APIs, including ghostty_snapshot_decoder_free(), on the decoder
|
||||
* that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with
|
||||
* zero bytes before a required checkpoint reports truncated snapshot data as
|
||||
* GHOSTTY_INVALID_VALUE.
|
||||
*
|
||||
* @param allocator Allocator for decoder and decoded terminal state, or NULL
|
||||
* for the default allocator
|
||||
* @param decoder Pointer to receive the decoder handle (must not be NULL)
|
||||
* @param reader Snapshot source reader
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new(
|
||||
const GhosttyAllocator* allocator,
|
||||
GhosttySnapshotDecoder* decoder,
|
||||
GhosttyReader reader);
|
||||
|
||||
/**
|
||||
* Create a snapshot decoder over a borrowed byte buffer.
|
||||
*
|
||||
* The bytes are not copied. ptr must remain valid and immutable until FINISH
|
||||
* is reached or the decoder is freed. Bytes after FINISH are not consumed;
|
||||
* query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them.
|
||||
*
|
||||
* @param allocator Allocator for decoder and decoded terminal state, or NULL
|
||||
* for the default allocator
|
||||
* @param decoder Pointer to receive the decoder handle (must not be NULL)
|
||||
* @param ptr Snapshot source bytes
|
||||
* @param len Number of source bytes
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new_buf(
|
||||
const GhosttyAllocator* allocator,
|
||||
GhosttySnapshotDecoder* decoder,
|
||||
const uint8_t* ptr,
|
||||
size_t len);
|
||||
|
||||
/**
|
||||
* Free a snapshot decoder.
|
||||
*
|
||||
* This does not release the caller's ownership of a terminal returned by
|
||||
* ready or decode. Abandoning an incremental decode leaves that terminal
|
||||
* usable with whatever history had already been restored.
|
||||
*
|
||||
* @param decoder Decoder to free (may be NULL)
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API void ghostty_snapshot_decoder_free(GhosttySnapshotDecoder decoder);
|
||||
|
||||
/**
|
||||
* Set a snapshot decoder option.
|
||||
*
|
||||
* The value pointer must have the type documented by option. Options may only
|
||||
* be changed before decoding starts.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @param option Option to change
|
||||
* @param value Pointer to the option value (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has
|
||||
* started or an argument is invalid, or another error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set(
|
||||
GhosttySnapshotDecoder decoder,
|
||||
GhosttySnapshotDecoderOption option,
|
||||
const void* value);
|
||||
|
||||
/**
|
||||
* Decode and authenticate the renderable snapshot prefix through READY.
|
||||
*
|
||||
* On success, terminal receives a caller-owned terminal with its persistent
|
||||
* VT stream already restored from the snapshot continuation. The terminal is
|
||||
* immediately usable for rendering and live input. Older scrollback remains
|
||||
* to be restored with ghostty_snapshot_decoder_next().
|
||||
*
|
||||
* The restored parser state may be unfinished, but terminal continuation
|
||||
* tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns
|
||||
* zero. The decoder's continuation option is an input limit, not terminal
|
||||
* runtime policy.
|
||||
*
|
||||
* The caller must keep the returned terminal alive until FINISH validates or
|
||||
* the decoder is freed. The decoder borrows this terminal handle while it
|
||||
* restores history; ghostty_snapshot_decoder_next() uses it automatically.
|
||||
*
|
||||
* This operation may only be called once and only before decoding starts.
|
||||
* terminal is set to NULL on every error. A decoding, I/O, or allocation
|
||||
* error after input consumption begins poisons the decoder, after which it
|
||||
* must be freed. An invalid argument or lifecycle error detected before the
|
||||
* operation consumes input does not poison it.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @param[out] terminal Pointer to receive the terminal (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_ready(
|
||||
GhosttySnapshotDecoder decoder,
|
||||
GhosttyTerminal* terminal);
|
||||
|
||||
/**
|
||||
* Decode one history page into the terminal returned by READY.
|
||||
*
|
||||
* Each GHOSTTY_SUCCESS consumes and authenticates one PAGE record. Query the
|
||||
* GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again.
|
||||
* GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH
|
||||
* also return GHOSTTY_NO_VALUE.
|
||||
*
|
||||
* The terminal may be rendered, resized, and fed live PTY input between calls.
|
||||
* If a history page can no longer be applied safely, it is still consumed and
|
||||
* authenticated and progress reports zero rows. The decoder applies history
|
||||
* to the caller-owned terminal produced by its READY operation.
|
||||
*
|
||||
* A decoding error invalidates the decoder's source position. The terminal
|
||||
* remains caller-owned and usable with its already-restored history, but only
|
||||
* ghostty_snapshot_decoder_free() may subsequently be called on the decoder.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an
|
||||
* error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next(
|
||||
GhosttySnapshotDecoder decoder);
|
||||
|
||||
/**
|
||||
* Decode and authenticate one complete snapshot.
|
||||
*
|
||||
* This is the one-shot form of READY followed by all history pages through
|
||||
* FINISH. It may only be called before decoding starts. Bytes following FINISH
|
||||
* are left unread. On success terminal receives a caller-owned terminal with
|
||||
* its persistent VT stream restored. Continuation tracking on the returned
|
||||
* terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES
|
||||
* returns zero. terminal is set to NULL on every error.
|
||||
* A decoding, I/O, or allocation error after input consumption begins poisons
|
||||
* the decoder, after which it must be freed. An invalid argument or
|
||||
* lifecycle error detected before the operation consumes input does not
|
||||
* poison it.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @param[out] terminal Pointer to receive the terminal (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, or an error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_decode(
|
||||
GhosttySnapshotDecoder decoder,
|
||||
GhosttyTerminal* terminal);
|
||||
|
||||
/**
|
||||
* Get typed data from a snapshot decoder.
|
||||
*
|
||||
* The output pointer must have the type documented by data. A phase-dependent
|
||||
* value that is not currently available returns GHOSTTY_NO_VALUE.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @param data Data kind to query
|
||||
* @param[out] out Pointer to receive the value (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data
|
||||
* is unavailable, or another error code on failure
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get(
|
||||
GhosttySnapshotDecoder decoder,
|
||||
GhosttySnapshotDecoderData data,
|
||||
void* out);
|
||||
|
||||
/**
|
||||
* Get multiple snapshot decoder data fields in a single call.
|
||||
*
|
||||
* Each keys element selects a data kind and the corresponding values element
|
||||
* points to storage of the documented output type. Processing stops at the
|
||||
* first error. On success out_written is set to count; on error it is set to
|
||||
* the number of values written before the failing key. Invalid array arguments
|
||||
* report zero values written.
|
||||
*
|
||||
* @param decoder Decoder handle (must not be NULL)
|
||||
* @param count Number of key/value pairs
|
||||
* @param keys Array of data kinds to query
|
||||
* @param values Array of output pointers corresponding to keys
|
||||
* @param[out] out_written Number of successfully written values (may be NULL)
|
||||
* @return GHOSTTY_SUCCESS if every query succeeds, or the first error
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get_multi(
|
||||
GhosttySnapshotDecoder decoder,
|
||||
size_t count,
|
||||
const GhosttySnapshotDecoderData* keys,
|
||||
void** values,
|
||||
size_t* out_written);
|
||||
|
||||
/** @} */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GHOSTTY_VT_SNAPSHOT_H */
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <ghostty/vt/modes.h>
|
||||
#include <ghostty/vt/size_report.h>
|
||||
#include <ghostty/vt/grid_ref.h>
|
||||
#include <ghostty/vt/io.h>
|
||||
#include <ghostty/vt/kitty_graphics.h>
|
||||
#include <ghostty/vt/screen.h>
|
||||
#include <ghostty/vt/point.h>
|
||||
@@ -1027,6 +1028,26 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: GhosttyTerminalProgressReportFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30,
|
||||
|
||||
/**
|
||||
* Set the maximum number of replay-safe VT continuation bytes retained.
|
||||
*
|
||||
* Continuation bytes reconstruct an escape sequence or UTF-8 codepoint
|
||||
* which was unfinished at the end of the most recent
|
||||
* ghostty_terminal_vt_write() call. They are used automatically by terminal
|
||||
* snapshots and may also be exported directly with the continuation APIs.
|
||||
*
|
||||
* Tracking is disabled by default. A nonzero value enables tracking and
|
||||
* sets its byte limit. Passing NULL or a pointer to zero disables tracking.
|
||||
* Lowering the limit below an already-retained
|
||||
* continuation, or enabling tracking while the parser is already
|
||||
* unfinished, makes the current continuation unavailable because earlier
|
||||
* bytes cannot be reconstructed. Tracking recovers automatically after a
|
||||
* later write reaches the ground state or contains a fresh replay start.
|
||||
*
|
||||
* Input type: size_t*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES = 31,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
@@ -1376,6 +1397,17 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES = 35,
|
||||
|
||||
/**
|
||||
* The configured maximum retained VT continuation size in bytes.
|
||||
*
|
||||
* A value of zero means continuation tracking is disabled. This reports the
|
||||
* configured limit even when a current unfinished continuation is
|
||||
* temporarily unavailable.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES = 36,
|
||||
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalData;
|
||||
|
||||
@@ -1501,6 +1533,97 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
|
||||
const uint8_t* data,
|
||||
size_t len);
|
||||
|
||||
/**
|
||||
* Write the terminal's replay-safe VT continuation to a callback writer.
|
||||
*
|
||||
* The continuation is the exact byte suffix needed to reconstruct unfinished
|
||||
* VT parser or UTF-8 decoder state in an equivalent terminal. It is empty
|
||||
* when the stream is at ground. The callback is invoked synchronously and
|
||||
* may be called more than once. It must not call terminal APIs with the same
|
||||
* terminal handle.
|
||||
*
|
||||
* Continuation tracking must have been enabled by setting
|
||||
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
|
||||
* input that produced the continuation was written.
|
||||
*
|
||||
* The caller must serialize this operation with ghostty_terminal_vt_write()
|
||||
* and all other access to the same terminal.
|
||||
*
|
||||
* @param terminal Terminal to read from (must not be NULL)
|
||||
* @param writer Destination writer whose write callback must not be NULL
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects
|
||||
* a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or
|
||||
* GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is
|
||||
* disabled, or the current continuation is unavailable
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_write(
|
||||
GhosttyTerminal terminal,
|
||||
GhosttyWriter writer);
|
||||
|
||||
/**
|
||||
* Copy the terminal's replay-safe VT continuation into a caller buffer.
|
||||
*
|
||||
* Pass NULL for buf with buf_len zero to query the required size. A size query
|
||||
* returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written,
|
||||
* including zero when the stream is at ground. If a non-NULL buffer is too
|
||||
* small, the function has the same result and reports the full required size.
|
||||
* Continuation tracking must have been enabled by setting
|
||||
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
|
||||
* input that produced the continuation was written.
|
||||
*
|
||||
* The caller must serialize this operation with all other access to the same
|
||||
* terminal.
|
||||
*
|
||||
* @param terminal Terminal to read from (must not be NULL)
|
||||
* @param buf Destination buffer, or NULL when buf_len is zero
|
||||
* @param buf_len Destination buffer capacity in bytes
|
||||
* @param[out] out_written Bytes written, or required size on
|
||||
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or
|
||||
* insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is
|
||||
* invalid, tracking is disabled, or the current continuation is
|
||||
* unavailable
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_buf(
|
||||
GhosttyTerminal terminal,
|
||||
uint8_t* buf,
|
||||
size_t buf_len,
|
||||
size_t* out_written);
|
||||
|
||||
/**
|
||||
* Return an allocated copy of the terminal's replay-safe VT continuation.
|
||||
*
|
||||
* The returned bytes are allocated with allocator, or the default allocator
|
||||
* when allocator is NULL. The caller must release them with ghostty_free(),
|
||||
* passing the same allocator and returned length. An empty continuation is a
|
||||
* successful zero-length allocation.
|
||||
* Continuation tracking must have been enabled by setting
|
||||
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
|
||||
* input that produced the continuation was written.
|
||||
*
|
||||
* The caller must serialize this operation with all other access to the same
|
||||
* terminal.
|
||||
*
|
||||
* @param terminal Terminal to read from (must not be NULL)
|
||||
* @param allocator Allocator for the output, or NULL for the default allocator
|
||||
* @param[out] out_ptr Allocated continuation bytes (must not be NULL)
|
||||
* @param[out] out_len Number of continuation bytes (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
|
||||
* failure, or GHOSTTY_INVALID_VALUE if an argument is invalid,
|
||||
* tracking is disabled, or the current continuation is unavailable
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_alloc(
|
||||
GhosttyTerminal terminal,
|
||||
const GhosttyAllocator* allocator,
|
||||
uint8_t** out_ptr,
|
||||
size_t* out_len);
|
||||
|
||||
/**
|
||||
* Scroll the terminal viewport.
|
||||
*
|
||||
|
||||
@@ -82,6 +82,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
GHOSTTY_OUT_OF_SPACE = -3,
|
||||
/** The requested value has no value */
|
||||
GHOSTTY_NO_VALUE = -4,
|
||||
/** Operation failed while reading from or writing to external I/O */
|
||||
GHOSTTY_IO_ERROR = -5,
|
||||
/** Operation failed because encoded input exceeded a configured limit */
|
||||
GHOSTTY_LIMIT_EXCEEDED = -6,
|
||||
GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyResult;
|
||||
|
||||
@@ -94,6 +98,13 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
*/
|
||||
typedef struct GhosttyTerminalImpl* GhosttyTerminal;
|
||||
|
||||
/**
|
||||
* Opaque handle to an incremental terminal snapshot decoder.
|
||||
*
|
||||
* @ingroup snapshot
|
||||
*/
|
||||
typedef struct GhosttySnapshotDecoderImpl* GhosttySnapshotDecoder;
|
||||
|
||||
/**
|
||||
* Opaque handle to a tracked grid reference.
|
||||
*
|
||||
|
||||
@@ -288,6 +288,9 @@ comptime {
|
||||
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
|
||||
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
|
||||
@export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
|
||||
@export(&c.terminal_continuation_write, .{ .name = "ghostty_terminal_continuation_write" });
|
||||
@export(&c.terminal_continuation_buf, .{ .name = "ghostty_terminal_continuation_buf" });
|
||||
@export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" });
|
||||
@export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" });
|
||||
@export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" });
|
||||
@export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" });
|
||||
@@ -310,6 +313,18 @@ comptime {
|
||||
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
|
||||
@export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" });
|
||||
@export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" });
|
||||
@export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" });
|
||||
@export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" });
|
||||
@export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" });
|
||||
@export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" });
|
||||
@export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" });
|
||||
@export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" });
|
||||
@export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" });
|
||||
@export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" });
|
||||
@export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" });
|
||||
@export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" });
|
||||
@export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" });
|
||||
@export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" });
|
||||
@export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" });
|
||||
@export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" });
|
||||
@export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" });
|
||||
|
||||
@@ -248,6 +248,17 @@ pub const Protocol = enum {
|
||||
.glyph => 1 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return the largest default buffer limit across every APC protocol.
|
||||
/// Consumers that must retain any unfinished APC can derive their limit
|
||||
/// here instead of duplicating a particular protocol's current default.
|
||||
pub fn maxDefaultBytes() usize {
|
||||
var result: usize = 0;
|
||||
for (std.enums.values(Protocol)) |protocol| {
|
||||
result = @max(result, protocol.defaultMaxBytes());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
/// Possible APC commands.
|
||||
|
||||
594
src/terminal/c/io.zig
Normal file
594
src/terminal/c/io.zig
Normal file
@@ -0,0 +1,594 @@
|
||||
//! C byte-stream callbacks and adapters for `std.Io`.
|
||||
//!
|
||||
//! The public callbacks deliberately expose a smaller contract than Zig's I/O
|
||||
//! interfaces: reads are synchronous progress/EOF/error, and writes accept an
|
||||
//! entire slice or fail. These adapters translate that contract while keeping
|
||||
//! enough state to map failures back to distinct C result codes.
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const lib = @import("../lib.zig");
|
||||
|
||||
/// C: GhosttyReaderFn
|
||||
pub const ReaderFn = *const fn (
|
||||
userdata: ?*anyopaque,
|
||||
buffer: [*]u8,
|
||||
capacity: usize,
|
||||
out_read: *usize,
|
||||
) callconv(lib.calling_conv) bool;
|
||||
|
||||
/// C: GhosttyWriterFn
|
||||
pub const WriterFn = *const fn (
|
||||
userdata: ?*anyopaque,
|
||||
data: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) bool;
|
||||
|
||||
/// C: GhosttyReader
|
||||
pub const Reader = extern struct {
|
||||
read: ?ReaderFn = null,
|
||||
userdata: ?*anyopaque = null,
|
||||
|
||||
pub fn valid(self: Reader) bool {
|
||||
return self.read != null;
|
||||
}
|
||||
};
|
||||
|
||||
/// C: GhosttyWriter
|
||||
pub const Writer = extern struct {
|
||||
write: ?WriterFn = null,
|
||||
userdata: ?*anyopaque = null,
|
||||
|
||||
pub fn valid(self: Writer) bool {
|
||||
return self.write != null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Adapts a `GhosttyReader` to `std.Io.Reader`.
|
||||
///
|
||||
/// The adapter must have a stable address while `interface` is in use. Its
|
||||
/// interface starts unbuffered so `init` can safely return it by value. A
|
||||
/// one-byte buffer is attached lazily when an operation such as `peekByte`
|
||||
/// requires buffering. This also prevents reads beyond a requested framing
|
||||
/// boundary.
|
||||
pub const ReaderAdapter = struct {
|
||||
/// Copy of the C callback pair. The pointed-to userdata remains borrowed.
|
||||
source: Reader,
|
||||
|
||||
/// Zig-facing interface. Its vtable recovers this enclosing adapter with
|
||||
/// `@fieldParentPtr`, which is why the adapter's address must remain stable.
|
||||
interface: std.Io.Reader,
|
||||
|
||||
/// Bytes successfully obtained from the callback.
|
||||
offset: usize = 0,
|
||||
|
||||
/// The callback returned false. This distinguishes an I/O failure from
|
||||
/// the callback returning true with zero bytes at end-of-file.
|
||||
callback_failed: bool = false,
|
||||
|
||||
/// The callback was NULL, returned too many bytes, or overflowed offset.
|
||||
invalid_read: bool = false,
|
||||
|
||||
/// The callback returned true with zero bytes. EOF is permanent.
|
||||
eof: bool = false,
|
||||
|
||||
/// `std.Io.Reader` needs backing storage for `peekByte`. One byte is
|
||||
/// intentional: it satisfies that operation without allowing the adapter
|
||||
/// to pull a second byte across a snapshot checkpoint or FINISH boundary.
|
||||
peek_buffer: [1]u8 = undefined,
|
||||
|
||||
/// Scratch space used only when the destination writer has no writable
|
||||
/// buffer of its own. 4 KiB amortizes callback overhead for streaming
|
||||
/// operations while keeping every adapter's fixed allocation modest. The
|
||||
/// caller's `limit` always truncates this slice, so the size cannot cause
|
||||
/// framing read-ahead.
|
||||
transfer_buffer: [4096]u8 = undefined,
|
||||
|
||||
pub fn init(source: Reader) ReaderAdapter {
|
||||
return .{
|
||||
.source = source,
|
||||
.interface = .{
|
||||
.vtable = &.{
|
||||
.stream = stream,
|
||||
.rebase = rebase,
|
||||
},
|
||||
// `rebase` attaches peek_buffer after the adapter reaches its
|
||||
// stable destination address.
|
||||
.buffer = &.{},
|
||||
.seek = 0,
|
||||
.end = 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn reader(self: *ReaderAdapter) *std.Io.Reader {
|
||||
// Calling this only after the adapter reaches its final address makes
|
||||
// the parent-pointer recovery in `stream` and `rebase` valid.
|
||||
return &self.interface;
|
||||
}
|
||||
|
||||
/// Perform exactly one C callback invocation into caller-selected storage.
|
||||
/// This is the single place that interprets the public callback contract.
|
||||
fn readInto(self: *ReaderAdapter, destination: []u8) std.Io.Reader.Error!usize {
|
||||
assert(destination.len > 0);
|
||||
|
||||
// Failure and EOF are sticky. In particular, a callback that reports
|
||||
// EOF is never polled again because the C API defines it as permanent.
|
||||
if (self.callback_failed or self.invalid_read) return error.ReadFailed;
|
||||
if (self.eof) return error.EndOfStream;
|
||||
|
||||
// A missing callback is an invalid C value rather than external I/O
|
||||
// failure, so record it separately for the public result mapper.
|
||||
const read_fn = self.source.read orelse {
|
||||
self.invalid_read = true;
|
||||
return error.ReadFailed;
|
||||
};
|
||||
|
||||
// Initialize this ourselves: a callback returning false is not
|
||||
// required to initialize out_read.
|
||||
var read_len: usize = 0;
|
||||
if (!read_fn(
|
||||
self.source.userdata,
|
||||
destination.ptr,
|
||||
destination.len,
|
||||
&read_len,
|
||||
)) {
|
||||
self.callback_failed = true;
|
||||
return error.ReadFailed;
|
||||
}
|
||||
|
||||
// Treat a callback that violates the capacity contract as an invalid
|
||||
// reader without constructing an out-of-bounds slice.
|
||||
if (read_len > destination.len) {
|
||||
self.invalid_read = true;
|
||||
return error.ReadFailed;
|
||||
}
|
||||
// Successful zero-length reads are the C representation of EOF.
|
||||
if (read_len == 0) {
|
||||
self.eof = true;
|
||||
return error.EndOfStream;
|
||||
}
|
||||
|
||||
// Offset counts only bytes actually supplied by successful callbacks.
|
||||
// Saturating or wrapping would make SOURCE_OFFSET untrustworthy.
|
||||
self.offset = std.math.add(usize, self.offset, read_len) catch {
|
||||
self.invalid_read = true;
|
||||
return error.ReadFailed;
|
||||
};
|
||||
return read_len;
|
||||
}
|
||||
|
||||
/// `stream` is the fundamental read primitive in `std.Io.Reader`'s
|
||||
/// vtable. It moves at most `limit` bytes from the C source to the Zig
|
||||
/// writer and reports the number transferred. Higher-level operations
|
||||
/// such as `readSliceAll` and `peekByte` are implemented in terms of it.
|
||||
fn stream(
|
||||
reader_: *std.Io.Reader,
|
||||
writer: *std.Io.Writer,
|
||||
limit: std.Io.Limit,
|
||||
) std.Io.Reader.StreamError!usize {
|
||||
// A zero limit is a successful no-op and must not invoke user code.
|
||||
if (limit == .nothing) return 0;
|
||||
|
||||
// The std.Io callback receives only the embedded interface pointer.
|
||||
// Recover the adapter to reach the C callback and accounting flags.
|
||||
const self: *ReaderAdapter = @alignCast(@fieldParentPtr(
|
||||
"interface",
|
||||
reader_,
|
||||
));
|
||||
|
||||
// Use the destination's storage directly when possible. This is the
|
||||
// normal path for readSlice operations and for filling peek_buffer.
|
||||
const direct = limit.slice(writer.unusedCapacitySlice());
|
||||
if (direct.len > 0) {
|
||||
const read_len = try self.readInto(direct);
|
||||
// `readInto` initialized these bytes outside the Writer API, so
|
||||
// explicitly publish them to the destination.
|
||||
writer.advance(read_len);
|
||||
return read_len;
|
||||
}
|
||||
|
||||
// An unbuffered Writer has no direct destination storage. Read into
|
||||
// bounded adapter storage and then let its drain implementation take
|
||||
// ownership of the complete slice.
|
||||
const transfer = limit.slice(&self.transfer_buffer);
|
||||
assert(transfer.len > 0);
|
||||
const read_len = try self.readInto(transfer);
|
||||
// writeAll either transfers this complete callback result or reports
|
||||
// failure; returning a partial count would violate stream's contract.
|
||||
try writer.writeAll(transfer[0..read_len]);
|
||||
return read_len;
|
||||
}
|
||||
|
||||
/// Give buffered reader operations contiguous storage on first demand.
|
||||
/// Initialization cannot point at `peek_buffer` because `init` returns the
|
||||
/// adapter by value and its final address is not known until afterward.
|
||||
fn rebase(
|
||||
reader_: *std.Io.Reader,
|
||||
capacity: usize,
|
||||
) std.Io.Reader.RebaseError!void {
|
||||
const self: *ReaderAdapter = @alignCast(@fieldParentPtr(
|
||||
"interface",
|
||||
reader_,
|
||||
));
|
||||
|
||||
if (reader_.buffer.len == 0) {
|
||||
// No operation could have buffered bytes before storage existed.
|
||||
assert(reader_.seek == 0);
|
||||
assert(reader_.end == 0);
|
||||
reader_.buffer = &self.peek_buffer;
|
||||
}
|
||||
|
||||
// Like other std.Io.Reader implementations, contiguous peek capacity
|
||||
// is limited by the reader's declared buffer capacity.
|
||||
assert(capacity <= reader_.buffer.len);
|
||||
// Avoid the default memmove when the unread tail already has room.
|
||||
if (reader_.buffer.len - reader_.seek >= capacity) return;
|
||||
return std.Io.Reader.defaultRebase(reader_, capacity);
|
||||
}
|
||||
};
|
||||
|
||||
/// Adapts a `GhosttyWriter` to an unbuffered `std.Io.Writer`.
|
||||
///
|
||||
/// Keeping the Zig writer unbuffered ensures success means the C callback has
|
||||
/// already accepted every byte; there is no hidden flush step at API return.
|
||||
pub const WriterAdapter = struct {
|
||||
/// Copy of the C callback pair. The pointed-to userdata remains borrowed.
|
||||
destination: Writer,
|
||||
|
||||
/// Zig-facing interface whose drain vtable points back to this adapter.
|
||||
interface: std.Io.Writer,
|
||||
|
||||
/// Bytes accepted by successful callback invocations.
|
||||
offset: usize = 0,
|
||||
|
||||
/// The callback returned false.
|
||||
callback_failed: bool = false,
|
||||
|
||||
/// The callback was NULL or offset accounting overflowed.
|
||||
invalid_write: bool = false,
|
||||
|
||||
pub fn init(destination: Writer) WriterAdapter {
|
||||
return .{
|
||||
.destination = destination,
|
||||
.interface = .{
|
||||
// With no buffer, every Zig write reaches `drain` immediately.
|
||||
.vtable = &.{ .drain = drain },
|
||||
.buffer = &.{},
|
||||
.end = 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn writer(self: *WriterAdapter) *std.Io.Writer {
|
||||
// As with ReaderAdapter, the returned interface borrows this stable
|
||||
// enclosing address for parent-pointer recovery in the vtable.
|
||||
return &self.interface;
|
||||
}
|
||||
|
||||
/// Submit one ordinary slice through the all-or-nothing C write callback.
|
||||
fn writeAll(self: *WriterAdapter, data: []const u8) std.Io.Writer.Error!void {
|
||||
// Empty writes carry no information and should not call foreign code.
|
||||
if (data.len == 0) return;
|
||||
|
||||
// Make failures sticky so a higher-level Zig retry cannot produce a
|
||||
// misleading second callback after the output is already partial.
|
||||
if (self.callback_failed or self.invalid_write) return error.WriteFailed;
|
||||
|
||||
// Missing callbacks are invalid arguments; false callback returns are
|
||||
// external I/O errors. Preserve that distinction for the C wrapper.
|
||||
const write_fn = self.destination.write orelse {
|
||||
self.invalid_write = true;
|
||||
return error.WriteFailed;
|
||||
};
|
||||
if (!write_fn(self.destination.userdata, data.ptr, data.len)) {
|
||||
self.callback_failed = true;
|
||||
return error.WriteFailed;
|
||||
}
|
||||
|
||||
// Count bytes only after the callback accepts the complete slice.
|
||||
self.offset = std.math.add(usize, self.offset, data.len) catch {
|
||||
self.invalid_write = true;
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
|
||||
/// Implement the sole primitive required by an unbuffered std.Io.Writer.
|
||||
///
|
||||
/// Zig represents a vector write as ordinary slices followed by the last
|
||||
/// slice repeated `splat` times. The C callback has no vector form, so this
|
||||
/// method expands that representation into ordered all-or-nothing calls
|
||||
/// and returns the total logical byte count consumed.
|
||||
fn drain(
|
||||
writer_: *std.Io.Writer,
|
||||
data: []const []const u8,
|
||||
splat: usize,
|
||||
) std.Io.Writer.Error!usize {
|
||||
assert(data.len > 0); // The final element is always the splat pattern.
|
||||
assert(writer_.end == 0); // This adapter intentionally has no buffer.
|
||||
|
||||
// The vtable receives the embedded Writer rather than our adapter.
|
||||
const self: *WriterAdapter = @alignCast(@fieldParentPtr(
|
||||
"interface",
|
||||
writer_,
|
||||
));
|
||||
|
||||
var consumed: usize = 0;
|
||||
// Every element except the last is written exactly once.
|
||||
for (data[0 .. data.len - 1]) |slice| {
|
||||
try self.writeAll(slice);
|
||||
consumed = std.math.add(usize, consumed, slice.len) catch {
|
||||
self.invalid_write = true;
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
|
||||
// std.Io uses the final element as a compact repeated pattern. A
|
||||
// splat of zero means the final element is not part of this write.
|
||||
const pattern = data[data.len - 1];
|
||||
for (0..splat) |_| {
|
||||
try self.writeAll(pattern);
|
||||
consumed = std.math.add(usize, consumed, pattern.len) catch {
|
||||
self.invalid_write = true;
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
};
|
||||
|
||||
test "C reader and writer layouts keep callback first" {
|
||||
try std.testing.expectEqual(@as(usize, 0), @offsetOf(Reader, "read"));
|
||||
try std.testing.expectEqual(@sizeOf(?ReaderFn), @offsetOf(Reader, "userdata"));
|
||||
try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader));
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write"));
|
||||
try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata"));
|
||||
try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer));
|
||||
}
|
||||
|
||||
test "ReaderAdapter supports short reads and permanent EOF" {
|
||||
const Context = struct {
|
||||
data: []const u8,
|
||||
offset: usize = 0,
|
||||
calls: usize = 0,
|
||||
|
||||
fn read(
|
||||
userdata: ?*anyopaque,
|
||||
buffer: [*]u8,
|
||||
capacity: usize,
|
||||
out_read: *usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
self.calls += 1;
|
||||
const remaining = self.data[self.offset..];
|
||||
const len = @min(remaining.len, capacity, 2);
|
||||
@memcpy(buffer[0..len], remaining[0..len]);
|
||||
self.offset += len;
|
||||
out_read.* = len;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
var context: Context = .{ .data = "abcdef" };
|
||||
var adapter: ReaderAdapter = .init(.{
|
||||
.read = &Context.read,
|
||||
.userdata = &context,
|
||||
});
|
||||
|
||||
var actual: [6]u8 = undefined;
|
||||
try adapter.interface.readSliceAll(&actual);
|
||||
try std.testing.expectEqualStrings("abcdef", &actual);
|
||||
try std.testing.expectEqual(@as(usize, 6), adapter.offset);
|
||||
try std.testing.expect(!adapter.eof);
|
||||
|
||||
try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte());
|
||||
try std.testing.expect(adapter.eof);
|
||||
try std.testing.expect(!adapter.callback_failed);
|
||||
|
||||
const calls_at_eof = context.calls;
|
||||
try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte());
|
||||
try std.testing.expectEqual(calls_at_eof, context.calls);
|
||||
}
|
||||
|
||||
test "ReaderAdapter peek reads only the requested byte" {
|
||||
const Context = struct {
|
||||
data: []const u8,
|
||||
offset: usize = 0,
|
||||
last_capacity: usize = 0,
|
||||
|
||||
fn read(
|
||||
userdata: ?*anyopaque,
|
||||
buffer: [*]u8,
|
||||
capacity: usize,
|
||||
out_read: *usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
self.last_capacity = capacity;
|
||||
const remaining = self.data[self.offset..];
|
||||
const len = @min(remaining.len, capacity);
|
||||
@memcpy(buffer[0..len], remaining[0..len]);
|
||||
self.offset += len;
|
||||
out_read.* = len;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
var context: Context = .{ .data = "ab" };
|
||||
var adapter: ReaderAdapter = .init(.{
|
||||
.read = &Context.read,
|
||||
.userdata = &context,
|
||||
});
|
||||
|
||||
try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.peekByte());
|
||||
try std.testing.expectEqual(@as(usize, 1), context.last_capacity);
|
||||
try std.testing.expectEqual(@as(usize, 1), adapter.offset);
|
||||
try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.takeByte());
|
||||
try std.testing.expectEqual(@as(usize, 1), adapter.offset);
|
||||
}
|
||||
|
||||
test "ReaderAdapter distinguishes callback failure and invalid length" {
|
||||
const Failing = struct {
|
||||
fn read(
|
||||
_: ?*anyopaque,
|
||||
_: [*]u8,
|
||||
_: usize,
|
||||
_: *usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
var failing: ReaderAdapter = .init(.{ .read = &Failing.read });
|
||||
try std.testing.expectError(error.ReadFailed, failing.interface.takeByte());
|
||||
try std.testing.expect(failing.callback_failed);
|
||||
try std.testing.expect(!failing.eof);
|
||||
try std.testing.expect(!failing.invalid_read);
|
||||
|
||||
const Oversized = struct {
|
||||
fn read(
|
||||
_: ?*anyopaque,
|
||||
_: [*]u8,
|
||||
capacity: usize,
|
||||
out_read: *usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
out_read.* = capacity + 1;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
var oversized: ReaderAdapter = .init(.{ .read = &Oversized.read });
|
||||
try std.testing.expectError(error.ReadFailed, oversized.interface.takeByte());
|
||||
try std.testing.expect(!oversized.callback_failed);
|
||||
try std.testing.expect(!oversized.eof);
|
||||
try std.testing.expect(oversized.invalid_read);
|
||||
}
|
||||
|
||||
test "ReaderAdapter rejects a null callback" {
|
||||
var adapter: ReaderAdapter = .init(.{});
|
||||
try std.testing.expectError(error.ReadFailed, adapter.interface.takeByte());
|
||||
try std.testing.expect(adapter.invalid_read);
|
||||
try std.testing.expect(!adapter.callback_failed);
|
||||
}
|
||||
|
||||
test "WriterAdapter writes vectors and splats" {
|
||||
const Context = struct {
|
||||
data: [32]u8 = undefined,
|
||||
len: usize = 0,
|
||||
|
||||
fn write(
|
||||
userdata: ?*anyopaque,
|
||||
data: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
@memcpy(self.data[self.len..][0..len], data[0..len]);
|
||||
self.len += len;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
var context: Context = .{};
|
||||
var adapter: WriterAdapter = .init(.{
|
||||
.write = &Context.write,
|
||||
.userdata = &context,
|
||||
});
|
||||
|
||||
var vectors: [2][]const u8 = .{ "ab", "cd" };
|
||||
try adapter.interface.writeVecAll(&vectors);
|
||||
var repeated: [1][]const u8 = .{"!"};
|
||||
try adapter.interface.writeSplatAll(&repeated, 2);
|
||||
|
||||
try std.testing.expectEqualStrings("abcd!!", context.data[0..context.len]);
|
||||
try std.testing.expectEqual(@as(usize, 6), adapter.offset);
|
||||
try std.testing.expect(!adapter.callback_failed);
|
||||
}
|
||||
|
||||
test "WriterAdapter makes callback failure sticky" {
|
||||
const Context = struct {
|
||||
calls: usize = 0,
|
||||
|
||||
fn write(
|
||||
userdata: ?*anyopaque,
|
||||
_: [*]const u8,
|
||||
_: usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
self.calls += 1;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
var context: Context = .{};
|
||||
var adapter: WriterAdapter = .init(.{
|
||||
.write = &Context.write,
|
||||
.userdata = &context,
|
||||
});
|
||||
try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x"));
|
||||
try std.testing.expect(adapter.callback_failed);
|
||||
try std.testing.expectEqual(@as(usize, 0), adapter.offset);
|
||||
|
||||
try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("y"));
|
||||
try std.testing.expectEqual(@as(usize, 1), context.calls);
|
||||
}
|
||||
|
||||
test "WriterAdapter rejects a null callback" {
|
||||
var adapter: WriterAdapter = .init(.{});
|
||||
try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x"));
|
||||
try std.testing.expect(adapter.invalid_write);
|
||||
try std.testing.expect(!adapter.callback_failed);
|
||||
}
|
||||
|
||||
test "ReaderAdapter streams into an unbuffered WriterAdapter" {
|
||||
const ReadContext = struct {
|
||||
data: []const u8,
|
||||
offset: usize = 0,
|
||||
|
||||
fn read(
|
||||
userdata: ?*anyopaque,
|
||||
buffer: [*]u8,
|
||||
capacity: usize,
|
||||
out_read: *usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
const remaining = self.data[self.offset..];
|
||||
const len = @min(remaining.len, capacity);
|
||||
@memcpy(buffer[0..len], remaining[0..len]);
|
||||
self.offset += len;
|
||||
out_read.* = len;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const WriteContext = struct {
|
||||
data: [8]u8 = undefined,
|
||||
len: usize = 0,
|
||||
|
||||
fn write(
|
||||
userdata: ?*anyopaque,
|
||||
data: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
@memcpy(self.data[self.len..][0..len], data[0..len]);
|
||||
self.len += len;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
var read_context: ReadContext = .{ .data = "stream" };
|
||||
var reader: ReaderAdapter = .init(.{
|
||||
.read = &ReadContext.read,
|
||||
.userdata = &read_context,
|
||||
});
|
||||
var write_context: WriteContext = .{};
|
||||
var writer: WriterAdapter = .init(.{
|
||||
.write = &WriteContext.write,
|
||||
.userdata = &write_context,
|
||||
});
|
||||
|
||||
try reader.interface.streamExact(&writer.interface, "stream".len);
|
||||
try std.testing.expectEqualStrings(
|
||||
"stream",
|
||||
write_context.data[0..write_context.len],
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, "stream".len), reader.offset);
|
||||
try std.testing.expectEqual(@as(usize, "stream".len), writer.offset);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub const focus = @import("focus.zig");
|
||||
pub const formatter = @import("formatter.zig");
|
||||
pub const grid_ref = @import("grid_ref.zig");
|
||||
pub const grid_ref_tracked = @import("grid_ref_tracked.zig");
|
||||
pub const io = @import("io.zig");
|
||||
pub const kitty_graphics = @import("kitty_graphics.zig");
|
||||
pub const kitty_graphics_get = kitty_graphics.get;
|
||||
pub const kitty_graphics_image = kitty_graphics.image_get_handle;
|
||||
@@ -41,6 +42,7 @@ pub const paste = @import("paste.zig");
|
||||
pub const row = @import("row.zig");
|
||||
pub const sgr = @import("sgr.zig");
|
||||
pub const size_report = @import("size_report.zig");
|
||||
pub const snapshot = @import("snapshot.zig");
|
||||
pub const style = @import("style.zig");
|
||||
pub const sys = @import("sys.zig");
|
||||
pub const terminal = @import("terminal.zig");
|
||||
@@ -189,6 +191,9 @@ pub const terminal_mode_get = terminal.mode_get;
|
||||
pub const terminal_mode_set = terminal.mode_set;
|
||||
pub const terminal_get = terminal.get;
|
||||
pub const terminal_get_multi = terminal.get_multi;
|
||||
pub const terminal_continuation_write = terminal.continuation_write;
|
||||
pub const terminal_continuation_buf = terminal.continuation_buf;
|
||||
pub const terminal_continuation_alloc = terminal.continuation_alloc;
|
||||
pub const terminal_select_word = selection.word;
|
||||
pub const terminal_select_word_between = selection.word_between;
|
||||
pub const terminal_select_line = selection.line;
|
||||
@@ -214,6 +219,19 @@ pub const terminal_grid_ref = terminal.grid_ref;
|
||||
pub const terminal_grid_ref_track = terminal.grid_ref_track;
|
||||
pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref;
|
||||
|
||||
pub const snapshot_encode = snapshot.encode;
|
||||
pub const snapshot_encode_buf = snapshot.encode_buf;
|
||||
pub const snapshot_encode_alloc = snapshot.encode_alloc;
|
||||
pub const snapshot_decoder_new = snapshot.decoder_new;
|
||||
pub const snapshot_decoder_new_buf = snapshot.decoder_new_buf;
|
||||
pub const snapshot_decoder_free = snapshot.decoder_free;
|
||||
pub const snapshot_decoder_set = snapshot.decoder_set;
|
||||
pub const snapshot_decoder_get = snapshot.decoder_get;
|
||||
pub const snapshot_decoder_get_multi = snapshot.decoder_get_multi;
|
||||
pub const snapshot_decoder_ready = snapshot.decoder_ready;
|
||||
pub const snapshot_decoder_next = snapshot.decoder_next;
|
||||
pub const snapshot_decoder_decode = snapshot.decoder_decode;
|
||||
|
||||
pub const type_json = types.get_json;
|
||||
|
||||
pub const unicode_codepoint_width = unicode.codepoint_width;
|
||||
@@ -238,6 +256,7 @@ test {
|
||||
_ = color_scheme;
|
||||
_ = grid_ref;
|
||||
_ = grid_ref_tracked;
|
||||
_ = io;
|
||||
_ = kitty_graphics;
|
||||
_ = row;
|
||||
_ = focus;
|
||||
@@ -254,6 +273,7 @@ test {
|
||||
_ = paste;
|
||||
_ = sgr;
|
||||
_ = size_report;
|
||||
_ = snapshot;
|
||||
_ = style;
|
||||
_ = sys;
|
||||
_ = terminal;
|
||||
|
||||
@@ -5,4 +5,6 @@ pub const Result = enum(c_int) {
|
||||
invalid_value = -2,
|
||||
out_of_space = -3,
|
||||
no_value = -4,
|
||||
io_error = -5,
|
||||
limit_exceeded = -6,
|
||||
};
|
||||
|
||||
1253
src/terminal/c/snapshot.zig
Normal file
1253
src/terminal/c/snapshot.zig
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,8 @@ const selection_c = @import("selection.zig");
|
||||
const style_c = @import("style.zig");
|
||||
const color = @import("../color.zig");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const c_io = @import("io.zig");
|
||||
const snapshot_core = @import("../snapshot/main.zig");
|
||||
const Result = @import("result.zig").Result;
|
||||
const assert = @import("../../quirks.zig").inlineAssert;
|
||||
|
||||
@@ -37,20 +39,62 @@ const max_path_bytes = if (builtin.os.tag == .freestanding) 4096 else std.fs.max
|
||||
|
||||
const log = std.log.scoped(.terminal_c);
|
||||
|
||||
/// C terminals do not retain replay bytes unless the embedding application
|
||||
/// opts in through `GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES`.
|
||||
pub const default_continuation_max_bytes: usize = 0;
|
||||
|
||||
/// Owns the `std.Io` implementation retained by every C terminal.
|
||||
///
|
||||
/// Snapshot decoding creates this before the native terminal exists and
|
||||
/// transfers it into the final C wrapper after READY.
|
||||
pub const Io = struct {
|
||||
impl: Impl,
|
||||
|
||||
/// Platform-specific storage backing the public `std.Io` value.
|
||||
const Impl = if (builtin.os.tag != .freestanding)
|
||||
*std.Io.Threaded
|
||||
else
|
||||
void;
|
||||
|
||||
/// Allocation failures possible while constructing an I/O owner.
|
||||
pub const Error = error{OutOfMemory};
|
||||
|
||||
/// Allocate the native I/O implementation when the platform requires it.
|
||||
pub fn init(alloc: std.mem.Allocator) Error!Io {
|
||||
if (comptime builtin.os.tag == .freestanding) return .{ .impl = {} };
|
||||
|
||||
const ptr = alloc.create(std.Io.Threaded) catch
|
||||
return error.OutOfMemory;
|
||||
ptr.* = .init_single_threaded;
|
||||
return .{ .impl = ptr };
|
||||
}
|
||||
|
||||
/// Return the value passed to native terminal construction and decoding.
|
||||
pub fn io(self: Io) std.Io {
|
||||
if (comptime builtin.os.tag == .freestanding) {
|
||||
return std.Io.failing;
|
||||
}
|
||||
return self.impl.io();
|
||||
}
|
||||
|
||||
/// Release an I/O implementation that has not already been transferred.
|
||||
pub fn deinit(self: Io, alloc: std.mem.Allocator) void {
|
||||
if (comptime builtin.os.tag != .freestanding) {
|
||||
self.impl.deinit();
|
||||
alloc.destroy(self.impl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Wrapper around ZigTerminal that tracks additional state for C API usage,
|
||||
/// such as the persistent VT stream needed to handle escape sequences split
|
||||
/// across multiple vt_write calls.
|
||||
const TerminalWrapper = struct {
|
||||
const IoImpl = if (builtin.os.tag != .freestanding) *std.Io.Threaded else void;
|
||||
|
||||
terminal: *ZigTerminal,
|
||||
/// We need to keep an I/O instance here as part of the terminal since we
|
||||
/// have no way of taking it in the C API. This is set up in `new` and
|
||||
/// destroyed on `free`.
|
||||
///
|
||||
/// This is set to null on freestanding platforms, which get std.Io.failing
|
||||
/// instead.
|
||||
io_impl: IoImpl,
|
||||
/// C construction has no I/O argument, so the wrapper retains the owner
|
||||
/// created by `new` or transferred from snapshot decoding until `free`.
|
||||
/// Freestanding owners contain no native allocation and expose failing I/O.
|
||||
io: Io,
|
||||
/// We also need to store a temp dir path for some operations (e.g., kitty
|
||||
/// graphics). This provides stable storage for the API calls.
|
||||
tmp_dir_path: [max_path_bytes]u8,
|
||||
@@ -373,6 +417,135 @@ pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
|
||||
return (terminal_ orelse return null).terminal;
|
||||
}
|
||||
|
||||
/// Attach the persistent C stream and I/O owner to a heap-stable terminal.
|
||||
/// The caller retains ownership of both inputs if wrapper allocation fails.
|
||||
fn wrap(
|
||||
alloc: std.mem.Allocator,
|
||||
t: *ZigTerminal,
|
||||
io: Io,
|
||||
continuation_max_bytes: usize,
|
||||
) error{OutOfMemory}!Terminal {
|
||||
const wrapper = alloc.create(TerminalWrapper) catch
|
||||
return error.OutOfMemory;
|
||||
|
||||
// Trampolines are always installed so setting C callbacks later takes
|
||||
// effect immediately.
|
||||
var handler: Stream.Handler = t.vtHandler();
|
||||
handler.effects = .{
|
||||
.write_pty = &Effects.writePtyTrampoline,
|
||||
.bell = &Effects.bellTrampoline,
|
||||
.color_scheme = &Effects.colorSchemeTrampoline,
|
||||
.desktop_notification = &Effects.desktopNotificationTrampoline,
|
||||
.device_attributes = &Effects.deviceAttributesTrampoline,
|
||||
.enquiry = &Effects.enquiryTrampoline,
|
||||
.xtversion = &Effects.xtversionTrampoline,
|
||||
.title_changed = &Effects.titleChangedTrampoline,
|
||||
.pwd_changed = &Effects.pwdChangedTrampoline,
|
||||
.progress_report = &Effects.progressReportTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
.clipboard_write = &Effects.clipboardWriteTrampoline,
|
||||
};
|
||||
|
||||
wrapper.* = .{
|
||||
.terminal = t,
|
||||
.io = io,
|
||||
.tmp_dir_path = undefined,
|
||||
.stream = Stream.init(.{
|
||||
.allocator = alloc,
|
||||
.handler = handler,
|
||||
.continuation_max_bytes = continuation_max_bytes,
|
||||
}),
|
||||
};
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
pub const RestoreContinuationError = error{
|
||||
OutOfMemory,
|
||||
ContinuationDisabled,
|
||||
ContinuationUnavailable,
|
||||
InvalidContinuation,
|
||||
};
|
||||
|
||||
/// Replay a decoded continuation exactly once into a newly created C terminal
|
||||
/// and verify that the persistent stream exports the identical canonical
|
||||
/// bytes. The terminal remains valid on error and may be freed normally.
|
||||
pub fn restoreContinuation(
|
||||
terminal_: Terminal,
|
||||
continuation: []const u8,
|
||||
) RestoreContinuationError!void {
|
||||
const wrapper = terminal_ orelse return error.InvalidContinuation;
|
||||
if (continuation.len > 0) wrapper.stream.nextSlice(continuation);
|
||||
|
||||
var exported: std.Io.Writer.Allocating = .init(wrapper.terminal.gpa());
|
||||
defer exported.deinit();
|
||||
wrapper.stream.writeContinuation(&exported.writer) catch |err| switch (err) {
|
||||
error.WriteFailed => return error.OutOfMemory,
|
||||
error.ContinuationDisabled => return error.ContinuationDisabled,
|
||||
error.ContinuationUnavailable => return error.ContinuationUnavailable,
|
||||
};
|
||||
if (!std.mem.eql(u8, continuation, exported.written())) {
|
||||
return error.InvalidContinuation;
|
||||
}
|
||||
}
|
||||
|
||||
pub const FromDecodedError = error{
|
||||
OutOfMemory,
|
||||
InvalidContinuation,
|
||||
};
|
||||
|
||||
/// Transfer a core snapshot result into a caller-owned C terminal.
|
||||
///
|
||||
/// This function consumes `io` on every path. The decoded terminal is
|
||||
/// transferred only after its final heap address has been allocated; its
|
||||
/// continuation remains in `decoded` and is replayed before returning.
|
||||
/// Replay uses a temporary exact-size tracker which is disabled before the
|
||||
/// terminal crosses the C ABI, restoring the ordinary C default policy.
|
||||
pub fn fromDecoded(
|
||||
alloc: std.mem.Allocator,
|
||||
io: Io,
|
||||
decoded: *snapshot_core.Decoded,
|
||||
) FromDecodedError!Terminal {
|
||||
const native = alloc.create(ZigTerminal) catch {
|
||||
io.deinit(alloc);
|
||||
return error.OutOfMemory;
|
||||
};
|
||||
native.* = decoded.toOwned();
|
||||
|
||||
const continuation = switch (decoded.continuation) {
|
||||
.ground => "",
|
||||
.bytes => |bytes| bytes,
|
||||
};
|
||||
|
||||
// Non-ground state needs tracking only long enough to verify that replay
|
||||
// reconstructed the exact canonical continuation. Ground state needs no
|
||||
// tracker at all.
|
||||
const terminal = wrap(alloc, native, io, continuation.len) catch |err| {
|
||||
native.deinit(alloc);
|
||||
alloc.destroy(native);
|
||||
io.deinit(alloc);
|
||||
return err;
|
||||
};
|
||||
errdefer free(terminal);
|
||||
|
||||
if (continuation.len > 0) {
|
||||
restoreContinuation(terminal, continuation) catch |err| return switch (err) {
|
||||
// The decoded bytes exactly fit this fresh tracker's cap, so
|
||||
// losing them while replaying can only be an allocation failure.
|
||||
error.OutOfMemory,
|
||||
error.ContinuationUnavailable,
|
||||
=> error.OutOfMemory,
|
||||
error.ContinuationDisabled,
|
||||
error.InvalidContinuation,
|
||||
=> error.InvalidContinuation,
|
||||
};
|
||||
}
|
||||
|
||||
// Decoder validation limits are not terminal runtime policy. A restored
|
||||
// terminal always starts with the same disabled tracking default as new.
|
||||
setContinuationMaxBytes(terminal.?, default_continuation_max_bytes);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
const NewError = error{
|
||||
InvalidValue,
|
||||
OutOfMemory,
|
||||
@@ -399,7 +572,7 @@ fn new_(
|
||||
alloc_: ?*const CAllocator,
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
) NewError!*TerminalWrapper {
|
||||
) NewError!Terminal {
|
||||
if (cols == 0 or rows == 0) return error.InvalidValue;
|
||||
|
||||
const alloc = lib.alloc.default(alloc_);
|
||||
@@ -407,21 +580,12 @@ fn new_(
|
||||
return error.OutOfMemory;
|
||||
errdefer alloc.destroy(t);
|
||||
|
||||
const wrapper = alloc.create(TerminalWrapper) catch
|
||||
return error.OutOfMemory;
|
||||
errdefer alloc.destroy(wrapper);
|
||||
|
||||
const has_nonfailing_io = builtin.os.tag != .freestanding;
|
||||
const io_impl: TerminalWrapper.IoImpl = if (has_nonfailing_io) io_impl: {
|
||||
const ptr = try alloc.create(std.Io.Threaded);
|
||||
ptr.* = .init_single_threaded;
|
||||
break :io_impl ptr;
|
||||
} else {};
|
||||
errdefer if (has_nonfailing_io) alloc.destroy(io_impl);
|
||||
const io = try Io.init(alloc);
|
||||
errdefer io.deinit(alloc);
|
||||
|
||||
// Setup our terminal
|
||||
t.* = try .init(
|
||||
if (has_nonfailing_io) io_impl.io() else std.Io.failing,
|
||||
io.io(),
|
||||
alloc,
|
||||
.{
|
||||
.cols = cols,
|
||||
@@ -435,35 +599,12 @@ fn new_(
|
||||
// Shells can still opt in with OSC 133;A;redraw=1.
|
||||
t.flags.shell_redraws_prompt = .false;
|
||||
|
||||
// Setup our stream with trampolines always installed so that
|
||||
// setting C callbacks at any time takes effect immediately.
|
||||
var handler: Stream.Handler = t.vtHandler();
|
||||
handler.effects = .{
|
||||
.write_pty = &Effects.writePtyTrampoline,
|
||||
.bell = &Effects.bellTrampoline,
|
||||
.color_scheme = &Effects.colorSchemeTrampoline,
|
||||
.desktop_notification = &Effects.desktopNotificationTrampoline,
|
||||
.device_attributes = &Effects.deviceAttributesTrampoline,
|
||||
.enquiry = &Effects.enquiryTrampoline,
|
||||
.xtversion = &Effects.xtversionTrampoline,
|
||||
.title_changed = &Effects.titleChangedTrampoline,
|
||||
.pwd_changed = &Effects.pwdChangedTrampoline,
|
||||
.progress_report = &Effects.progressReportTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
.clipboard_write = &Effects.clipboardWriteTrampoline,
|
||||
};
|
||||
|
||||
wrapper.* = .{
|
||||
.terminal = t,
|
||||
.io_impl = io_impl,
|
||||
.tmp_dir_path = undefined, // Only used if temporary directory is set with API calls
|
||||
.stream = Stream.init(.{
|
||||
.allocator = alloc,
|
||||
.handler = handler,
|
||||
}),
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
return try wrap(
|
||||
alloc,
|
||||
t,
|
||||
io,
|
||||
default_continuation_max_bytes,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn vt_write(
|
||||
@@ -475,6 +616,223 @@ pub fn vt_write(
|
||||
wrapper.stream.nextSlice(ptr[0..len]);
|
||||
}
|
||||
|
||||
pub const ContinuationWriteError = error{
|
||||
InvalidValue,
|
||||
WriteFailed,
|
||||
ContinuationDisabled,
|
||||
ContinuationUnavailable,
|
||||
};
|
||||
|
||||
/// Write the exact replay-safe continuation for a C terminal to a Zig writer.
|
||||
/// Snapshot encoding uses this helper so it can preflight continuation state
|
||||
/// without converting its allocator or writer through the C ABI.
|
||||
pub fn continuationWriteIo(
|
||||
terminal_: Terminal,
|
||||
writer: *std.Io.Writer,
|
||||
) ContinuationWriteError!void {
|
||||
// Keep handle validation here so the three public output forms and the
|
||||
// snapshot encoder all use exactly the same continuation preflight.
|
||||
const wrapper = terminal_ orelse return error.InvalidValue;
|
||||
|
||||
// Stream owns the tracker and distinguishes disabled tracking from a
|
||||
// tracker that lost bytes after allocation failure or limit overflow.
|
||||
try wrapper.stream.writeContinuation(writer);
|
||||
}
|
||||
|
||||
pub const ContinuationAllocError = error{
|
||||
InvalidValue,
|
||||
OutOfMemory,
|
||||
ContinuationDisabled,
|
||||
ContinuationUnavailable,
|
||||
};
|
||||
|
||||
/// Return an allocator-owned copy of the terminal's replay-safe continuation.
|
||||
/// The caller owns the returned slice and must free it with `alloc`.
|
||||
///
|
||||
/// If `allow_untracked_ground` is true, disabled tracking is accepted only
|
||||
/// when the stream is provably at ground and an owned empty slice is returned.
|
||||
pub fn continuationAllocIo(
|
||||
terminal_: Terminal,
|
||||
alloc: std.mem.Allocator,
|
||||
allow_untracked_ground: bool,
|
||||
) ContinuationAllocError![]u8 {
|
||||
const wrapper = terminal_ orelse return error.InvalidValue;
|
||||
if (allow_untracked_ground and
|
||||
wrapper.stream.continuation == null and
|
||||
wrapper.stream.ground())
|
||||
{
|
||||
return alloc.dupe(u8, "") catch error.OutOfMemory;
|
||||
}
|
||||
|
||||
// The allocating writer gives snapshot encoding and the public allocation
|
||||
// API one common way to obtain an exact owned continuation.
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
// A write failure from an Allocating writer can only be allocation failure;
|
||||
// no external callback participates in this path.
|
||||
continuationWriteIo(terminal_, &aw.writer) catch |err| switch (err) {
|
||||
error.InvalidValue => return error.InvalidValue,
|
||||
error.WriteFailed => return error.OutOfMemory,
|
||||
error.ContinuationDisabled => return error.ContinuationDisabled,
|
||||
error.ContinuationUnavailable => return error.ContinuationUnavailable,
|
||||
};
|
||||
|
||||
// Transfer the buffer out before the deferred writer cleanup runs.
|
||||
return aw.toOwnedSlice() catch error.OutOfMemory;
|
||||
}
|
||||
|
||||
/// Map errors that are intrinsic to continuation export. Public callback
|
||||
/// failures receive finer classification in `continuation_write` below.
|
||||
fn continuationErrorResult(err: ContinuationWriteError) Result {
|
||||
return switch (err) {
|
||||
error.InvalidValue => .invalid_value,
|
||||
error.WriteFailed => .io_error,
|
||||
error.ContinuationDisabled => .invalid_value,
|
||||
error.ContinuationUnavailable => .invalid_value,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn continuation_write(
|
||||
terminal_: Terminal,
|
||||
writer: c_io.Writer,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
// Reject the missing callback before invoking the common helper so this is
|
||||
// classified as a bad argument rather than a write failure.
|
||||
if (writer.write == null) return .invalid_value;
|
||||
|
||||
// The callback was validated above, so invalid_write can only mean output
|
||||
// accounting overflow. Keep that distinct from callback rejection.
|
||||
var adapter: c_io.WriterAdapter = .init(writer);
|
||||
continuationWriteIo(terminal_, &adapter.interface) catch |err| {
|
||||
if (err == error.WriteFailed) {
|
||||
if (adapter.invalid_write) return .limit_exceeded;
|
||||
if (adapter.callback_failed) return .io_error;
|
||||
}
|
||||
return continuationErrorResult(err);
|
||||
};
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn continuation_buf(
|
||||
terminal_: Terminal,
|
||||
out_: ?[*]u8,
|
||||
out_len: usize,
|
||||
out_written_: ?*usize,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const out_written = out_written_ orelse return .invalid_value;
|
||||
// All failure paths leave deterministic output metadata.
|
||||
out_written.* = 0;
|
||||
if (out_ == null and out_len != 0) return .invalid_value;
|
||||
|
||||
if (out_ == null) {
|
||||
// A null/zero destination is the explicit size-query form. Discarding
|
||||
// runs the real exporter, so disabled or unavailable tracking is still
|
||||
// detected before a required length is reported.
|
||||
var discarding: std.Io.Writer.Discarding = .init(&.{});
|
||||
continuationWriteIo(terminal_, &discarding.writer) catch |err|
|
||||
return continuationErrorResult(err);
|
||||
out_written.* = @intCast(discarding.count);
|
||||
return .out_of_space;
|
||||
}
|
||||
|
||||
// Fixed writers report WriteFailed when capacity is exhausted. The stream
|
||||
// exporter itself remains all-or-nothing from the API's perspective.
|
||||
var writer: std.Io.Writer = .fixed(out_.?[0..out_len]);
|
||||
continuationWriteIo(terminal_, &writer) catch |err| switch (err) {
|
||||
error.WriteFailed => {
|
||||
// Re-run against a counter to return the full required capacity,
|
||||
// not merely the prefix that fit in the caller's buffer.
|
||||
var discarding: std.Io.Writer.Discarding = .init(&.{});
|
||||
continuationWriteIo(terminal_, &discarding.writer) catch |count_err|
|
||||
return continuationErrorResult(count_err);
|
||||
out_written.* = @intCast(discarding.count);
|
||||
return .out_of_space;
|
||||
},
|
||||
else => return continuationErrorResult(err),
|
||||
};
|
||||
|
||||
// `end` is the initialized prefix of the fixed destination.
|
||||
out_written.* = writer.end;
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn continuation_alloc(
|
||||
terminal_: Terminal,
|
||||
alloc_: ?*const CAllocator,
|
||||
out_ptr_: ?*?[*]u8,
|
||||
out_len_: ?*usize,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const out_ptr = out_ptr_ orelse return .invalid_value;
|
||||
const out_len = out_len_ orelse return .invalid_value;
|
||||
// Make ownership unambiguous even if validation or allocation fails.
|
||||
out_ptr.* = null;
|
||||
out_len.* = 0;
|
||||
|
||||
// Resolve NULL to libghostty-vt's default allocator before entering the
|
||||
// shared Zig allocation path.
|
||||
const bytes = continuationAllocIo(
|
||||
terminal_,
|
||||
lib.alloc.default(alloc_),
|
||||
false,
|
||||
) catch |err| return switch (err) {
|
||||
error.InvalidValue => .invalid_value,
|
||||
error.OutOfMemory => .out_of_memory,
|
||||
error.ContinuationDisabled => .invalid_value,
|
||||
error.ContinuationUnavailable => .invalid_value,
|
||||
};
|
||||
|
||||
// Ownership crosses the ABI here; callers release this exact pointer and
|
||||
// length with ghostty_free and the same allocator selection.
|
||||
out_ptr.* = bytes.ptr;
|
||||
out_len.* = bytes.len;
|
||||
return .success;
|
||||
}
|
||||
|
||||
fn continuationMaxBytes(wrapper: *const TerminalWrapper) usize {
|
||||
// Absence of a tracker is the public representation of disabled tracking.
|
||||
return if (wrapper.stream.continuation) |tracker|
|
||||
tracker.max_bytes
|
||||
else
|
||||
0;
|
||||
}
|
||||
|
||||
/// Change continuation tracking policy without disturbing normal VT parser
|
||||
/// state. Bytes which were not retained while disabled or after exceeding an
|
||||
/// earlier cap cannot be reconstructed: export remains unavailable until a
|
||||
/// later feed reaches ground or contains a new replay start.
|
||||
fn setContinuationMaxBytes(wrapper: *TerminalWrapper, max_bytes: usize) void {
|
||||
if (max_bytes == 0) {
|
||||
// Disabling releases retained bytes immediately. Parser and UTF-8 state
|
||||
// continue normally; only future replay/export information is lost.
|
||||
if (wrapper.stream.continuation) |*tracker| tracker.deinit();
|
||||
wrapper.stream.continuation = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (wrapper.stream.continuation) |*tracker| {
|
||||
// Changing a live cap preserves retained bytes when they still fit.
|
||||
tracker.max_bytes = max_bytes;
|
||||
if (tracker.bytes.items.len > max_bytes) {
|
||||
// Once a retained prefix is discarded it cannot be reconstructed
|
||||
// from parser state alone. Mark it broken until Stream observes a
|
||||
// ground state or a new replay-safe sequence start.
|
||||
tracker.bytes.clearRetainingCapacity();
|
||||
tracker.broken = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enabling from zero starts an empty tracker owned by the terminal's
|
||||
// allocator. It can immediately track only if no earlier bytes are needed.
|
||||
wrapper.stream.continuation = .init(wrapper.terminal.gpa(), max_bytes);
|
||||
if (!wrapper.stream.ground()) {
|
||||
// The parser was already mid-sequence while tracking was disabled, so
|
||||
// exporting now would omit an unknown prefix.
|
||||
wrapper.stream.continuation.?.broken = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compression_activity(
|
||||
terminal_: Terminal,
|
||||
out_activity_: ?*u64,
|
||||
@@ -530,6 +888,7 @@ pub const Option = enum(c_int) {
|
||||
scrollback_max_lines = 28,
|
||||
desktop_notification = 29,
|
||||
progress_report = 30,
|
||||
continuation_max_bytes = 31,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -560,6 +919,7 @@ pub const Option = enum(c_int) {
|
||||
.apc_max_bytes_kitty,
|
||||
.scrollback_max_bytes,
|
||||
.scrollback_max_lines,
|
||||
.continuation_max_bytes,
|
||||
=> ?*const usize,
|
||||
.selection => ?*const selection_c.CSelection,
|
||||
.default_cursor_style => ?*const TerminalCursorStyle,
|
||||
@@ -720,6 +1080,10 @@ fn setTyped(
|
||||
.scrollback_max_lines => wrapper.terminal.setScrollbackMaxLines(
|
||||
if (value) |ptr| ptr.* else null,
|
||||
),
|
||||
.continuation_max_bytes => setContinuationMaxBytes(
|
||||
wrapper,
|
||||
if (value) |ptr| ptr.* else default_continuation_max_bytes,
|
||||
),
|
||||
}
|
||||
return .success;
|
||||
}
|
||||
@@ -861,6 +1225,7 @@ pub const TerminalData = enum(c_int) {
|
||||
vt_processing_error = 33,
|
||||
scrollback_max_bytes = 34,
|
||||
scrollback_max_lines = 35,
|
||||
continuation_max_bytes = 36,
|
||||
|
||||
/// Output type expected for querying the data of the given kind.
|
||||
pub fn OutType(comptime self: TerminalData) type {
|
||||
@@ -882,6 +1247,7 @@ pub const TerminalData = enum(c_int) {
|
||||
.scrollback_rows,
|
||||
.scrollback_max_bytes,
|
||||
.scrollback_max_lines,
|
||||
.continuation_max_bytes,
|
||||
=> usize,
|
||||
.width_px, .height_px => u32,
|
||||
.color_foreground,
|
||||
@@ -1028,6 +1394,7 @@ fn getTyped(
|
||||
if (max == std.math.maxInt(usize)) return .no_value;
|
||||
out.* = max;
|
||||
},
|
||||
.continuation_max_bytes => out.* = continuationMaxBytes(wrapper),
|
||||
}
|
||||
|
||||
return .success;
|
||||
@@ -1112,15 +1479,19 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void {
|
||||
wrapper.tracked_grid_refs.deinit(alloc);
|
||||
wrapper.stream.deinit();
|
||||
t.deinit(alloc);
|
||||
if (builtin.os.tag != .freestanding) {
|
||||
// Deinit is always safe to call, even for single-threaded instances
|
||||
wrapper.io_impl.deinit();
|
||||
alloc.destroy(wrapper.io_impl);
|
||||
}
|
||||
wrapper.io.deinit(alloc);
|
||||
alloc.destroy(t);
|
||||
alloc.destroy(wrapper);
|
||||
}
|
||||
|
||||
fn testEnableContinuation(terminal: Terminal) !void {
|
||||
const limit: usize = 1024;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(terminal, .continuation_max_bytes, &limit),
|
||||
);
|
||||
}
|
||||
|
||||
test "new/free" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
@@ -1154,6 +1525,318 @@ test "new invalid value" {
|
||||
try testing.expect(t == null);
|
||||
}
|
||||
|
||||
test "continuation option and data" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
var value: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .continuation_max_bytes, @ptrCast(&value)),
|
||||
);
|
||||
try testing.expectEqual(default_continuation_max_bytes, value);
|
||||
|
||||
const custom: usize = 1234;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &custom),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .continuation_max_bytes, @ptrCast(&value)),
|
||||
);
|
||||
try testing.expectEqual(custom, value);
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, null),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .continuation_max_bytes, @ptrCast(&value)),
|
||||
);
|
||||
try testing.expectEqual(default_continuation_max_bytes, value);
|
||||
|
||||
const disabled: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &disabled),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .continuation_max_bytes, @ptrCast(&value)),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), value);
|
||||
|
||||
var written: usize = 99;
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
continuation_buf(t, null, 0, &written),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), written);
|
||||
}
|
||||
|
||||
test "continuation buffer and allocator export exact suffix" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
try testEnableContinuation(t);
|
||||
|
||||
vt_write(t, "A\x1b[31", 5);
|
||||
|
||||
var required: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.out_of_space,
|
||||
continuation_buf(t, null, 0, &required),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 4), required);
|
||||
|
||||
var short: [2]u8 = undefined;
|
||||
try testing.expectEqual(
|
||||
Result.out_of_space,
|
||||
continuation_buf(t, &short, short.len, &required),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 4), required);
|
||||
|
||||
var buf: [8]u8 = undefined;
|
||||
var written: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
continuation_buf(t, &buf, buf.len, &written),
|
||||
);
|
||||
try testing.expectEqualStrings("\x1b[31", buf[0..written]);
|
||||
|
||||
var out_ptr: ?[*]u8 = null;
|
||||
var out_len: usize = 0;
|
||||
try testing.expectEqual(Result.success, continuation_alloc(
|
||||
t,
|
||||
&lib.alloc.test_allocator,
|
||||
&out_ptr,
|
||||
&out_len,
|
||||
));
|
||||
const allocated = out_ptr orelse return error.TestExpectedEqual;
|
||||
defer lib.alloc.default(&lib.alloc.test_allocator).free(allocated[0..out_len]);
|
||||
try testing.expectEqualStrings("\x1b[31", allocated[0..out_len]);
|
||||
|
||||
vt_write(t, "m", 1);
|
||||
try testing.expectEqual(
|
||||
Result.out_of_space,
|
||||
continuation_buf(t, null, 0, &required),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), required);
|
||||
}
|
||||
|
||||
test "continuation export tracks split UTF-8" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
try testEnableContinuation(t);
|
||||
|
||||
const prefix = [_]u8{ 0xF0, 0x9F };
|
||||
vt_write(t, &prefix, prefix.len);
|
||||
|
||||
var buf: [4]u8 = undefined;
|
||||
var written: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
continuation_buf(t, &buf, buf.len, &written),
|
||||
);
|
||||
try testing.expectEqualSlices(u8, &prefix, buf[0..written]);
|
||||
|
||||
const suffix = [_]u8{ 0x98, 0x80 };
|
||||
vt_write(t, &suffix, suffix.len);
|
||||
try testing.expectEqual(
|
||||
Result.out_of_space,
|
||||
continuation_buf(t, null, 0, &written),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), written);
|
||||
}
|
||||
|
||||
test "continuation callback writer reports success and failure" {
|
||||
const Sink = struct {
|
||||
bytes: [8]u8 = undefined,
|
||||
len: usize = 0,
|
||||
|
||||
fn write(
|
||||
userdata: ?*anyopaque,
|
||||
data: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
const self: *@This() = @ptrCast(@alignCast(userdata.?));
|
||||
if (len > self.bytes.len - self.len) return false;
|
||||
@memcpy(self.bytes[self.len..][0..len], data[0..len]);
|
||||
self.len += len;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn fail(
|
||||
_: ?*anyopaque,
|
||||
_: [*]const u8,
|
||||
_: usize,
|
||||
) callconv(lib.calling_conv) bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
try testEnableContinuation(t);
|
||||
vt_write(t, "\x1b[31", 4);
|
||||
|
||||
var sink: Sink = .{};
|
||||
try testing.expectEqual(Result.success, continuation_write(t, .{
|
||||
.write = &Sink.write,
|
||||
.userdata = &sink,
|
||||
}));
|
||||
try testing.expectEqualStrings("\x1b[31", sink.bytes[0..sink.len]);
|
||||
|
||||
try testing.expectEqual(Result.io_error, continuation_write(t, .{
|
||||
.write = &Sink.fail,
|
||||
}));
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
continuation_write(t, .{}),
|
||||
);
|
||||
try testing.expectEqual(Result.invalid_value, continuation_write(null, .{
|
||||
.write = &Sink.fail,
|
||||
}));
|
||||
}
|
||||
|
||||
test "continuation runtime reconfiguration recovers safely" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const enough: usize = 8;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &enough),
|
||||
);
|
||||
vt_write(t, "\x1b[123", 5);
|
||||
const too_small: usize = 2;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &too_small),
|
||||
);
|
||||
|
||||
var written: usize = 99;
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
continuation_buf(t, null, 0, &written),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), written);
|
||||
|
||||
// Raising the limit cannot reconstruct discarded bytes, but a new ESC is
|
||||
// a complete replay start and repairs tracking without first grounding.
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &enough),
|
||||
);
|
||||
vt_write(t, "\x1b[", 2);
|
||||
var buf: [8]u8 = undefined;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
continuation_buf(t, &buf, buf.len, &written),
|
||||
);
|
||||
try testing.expectEqualStrings("\x1b[", buf[0..written]);
|
||||
|
||||
const disabled: usize = 0;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &disabled),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
set(t, .continuation_max_bytes, &enough),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
continuation_buf(t, null, 0, &written),
|
||||
);
|
||||
|
||||
// Completing the sequence reaches ground and resets the broken marker.
|
||||
vt_write(t, "m", 1);
|
||||
try testing.expectEqual(
|
||||
Result.out_of_space,
|
||||
continuation_buf(t, null, 0, &written),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), written);
|
||||
}
|
||||
|
||||
test "continuation internal allocation and restoration" {
|
||||
var source: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&source,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(source);
|
||||
try testEnableContinuation(source);
|
||||
vt_write(source, "\x1b[31", 4);
|
||||
|
||||
const alloc = lib.alloc.default(&lib.alloc.test_allocator);
|
||||
const bytes = try continuationAllocIo(source, alloc, false);
|
||||
defer alloc.free(bytes);
|
||||
try testing.expectEqualStrings("\x1b[31", bytes);
|
||||
|
||||
var restored: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&restored,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(restored);
|
||||
try testEnableContinuation(restored);
|
||||
try restoreContinuation(restored, bytes);
|
||||
|
||||
const reexported = try continuationAllocIo(restored, alloc, false);
|
||||
defer alloc.free(reexported);
|
||||
try testing.expectEqualStrings(bytes, reexported);
|
||||
|
||||
var invalid: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&invalid,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(invalid);
|
||||
try testEnableContinuation(invalid);
|
||||
try testing.expectError(
|
||||
error.InvalidContinuation,
|
||||
restoreContinuation(invalid, "A"),
|
||||
);
|
||||
}
|
||||
|
||||
test "set scrollback limits" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -20,6 +20,7 @@ const render = @import("render.zig");
|
||||
const style_c = @import("style.zig");
|
||||
const mouse_encode = @import("mouse_encode.zig");
|
||||
const grid_ref = @import("grid_ref.zig");
|
||||
const io = @import("io.zig");
|
||||
|
||||
/// C: GhosttySurfacePosition
|
||||
pub const SurfacePosition = extern struct {
|
||||
@@ -60,6 +61,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
|
||||
.{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
|
||||
.{ "GhosttyPoint", StructInfo.init(point.Point.C) },
|
||||
.{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
|
||||
.{ "GhosttyReader", StructInfo.init(io.Reader) },
|
||||
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
|
||||
.{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) },
|
||||
.{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) },
|
||||
@@ -72,12 +74,13 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
|
||||
.{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) },
|
||||
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
|
||||
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
|
||||
.{ "GhosttyWriter", StructInfo.init(io.Writer) },
|
||||
});
|
||||
};
|
||||
|
||||
/// The comptime-generated JSON string of all structs.
|
||||
pub const json: [:0]const u8 = json: {
|
||||
@setEvalBranchQuota(100000);
|
||||
@setEvalBranchQuota(200_000);
|
||||
var counter: std.Io.Writer.Discarding = .init(&.{});
|
||||
jsonWriteAll(&counter.writer) catch unreachable;
|
||||
|
||||
@@ -218,6 +221,8 @@ test "json parses" {
|
||||
try std.testing.expect(root.contains("GhosttyClipboardContent"));
|
||||
try std.testing.expect(root.contains("GhosttyClipboardWrite"));
|
||||
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
|
||||
try std.testing.expect(root.contains("GhosttyReader"));
|
||||
try std.testing.expect(root.contains("GhosttyWriter"));
|
||||
|
||||
const clipboard_content = root.get("GhosttyClipboardContent").?.object;
|
||||
const clipboard_content_fields = clipboard_content.get("fields").?.object;
|
||||
@@ -231,6 +236,16 @@ test "json parses" {
|
||||
try std.testing.expect(clipboard_write_fields.contains("contents"));
|
||||
try std.testing.expect(clipboard_write_fields.contains("contents_len"));
|
||||
|
||||
const reader_fields = root.get("GhosttyReader").?.object
|
||||
.get("fields").?.object;
|
||||
try std.testing.expect(reader_fields.contains("read"));
|
||||
try std.testing.expect(reader_fields.contains("userdata"));
|
||||
|
||||
const writer_fields = root.get("GhosttyWriter").?.object
|
||||
.get("fields").?.object;
|
||||
try std.testing.expect(writer_fields.contains("write"));
|
||||
try std.testing.expect(writer_fields.contains("userdata"));
|
||||
|
||||
try std.testing.expect(!root.contains("GhosttyTerminalOptions"));
|
||||
}
|
||||
|
||||
|
||||
@@ -233,11 +233,14 @@ const builtin = @import("builtin");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const test_fixture = @import("fixture.zig");
|
||||
const io = @import("io.zig");
|
||||
const kitty = @import("../kitty.zig");
|
||||
const terminal_hyperlink = @import("../hyperlink.zig");
|
||||
const terminal_page = @import("../page.zig");
|
||||
const terminal_style = @import("../style.zig");
|
||||
|
||||
/// The Kitty virtual-placement placeholder remains wire-relevant even when
|
||||
/// runtime Kitty graphics support is compiled out.
|
||||
const kitty_virtual_placeholder: u21 = 0x10EEEE;
|
||||
|
||||
const TerminalCell = terminal_page.Cell;
|
||||
const TerminalHyperlinkId = terminal_hyperlink.Id;
|
||||
const TerminalPage = terminal_page.Page;
|
||||
@@ -957,7 +960,7 @@ fn applyCell(
|
||||
// version, but the placeholder is still a valid Unicode scalar.
|
||||
// Preserve it and derive the native row hint so later row
|
||||
// operations remain correct.
|
||||
if (wire.content == kitty.graphics.unicode.placeholder) {
|
||||
if (wire.content == kitty_virtual_placeholder) {
|
||||
row.kitty_virtual_placeholder = true;
|
||||
}
|
||||
},
|
||||
@@ -2020,7 +2023,7 @@ test "grid four-byte cells run full normalization" {
|
||||
// The Kitty placeholder does not fit two-byte cells but fits here
|
||||
// and must still derive the native row hint.
|
||||
try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{
|
||||
.content = kitty.graphics.unicode.placeholder,
|
||||
.content = kitty_virtual_placeholder,
|
||||
}))));
|
||||
|
||||
// An unknown small style reference degrades to the default style.
|
||||
@@ -2042,7 +2045,7 @@ test "grid four-byte cells run full normalization" {
|
||||
|
||||
const first = page.getRowAndCell(0, 0);
|
||||
try testing.expectEqual(
|
||||
@as(u21, kitty.graphics.unicode.placeholder),
|
||||
kitty_virtual_placeholder,
|
||||
first.cell.codepoint(),
|
||||
);
|
||||
try testing.expect(first.row.kitty_virtual_placeholder);
|
||||
|
||||
@@ -553,7 +553,7 @@ pub fn Stream(comptime H: type) type {
|
||||
|
||||
/// True when no continuation suffix is needed to reproduce the
|
||||
/// stream's current parsing state.
|
||||
inline fn ground(self: *const Self) bool {
|
||||
pub inline fn ground(self: *const Self) bool {
|
||||
// Parser ground alone is not sufficient because the UTF-8
|
||||
// decoder may have some state.
|
||||
return self.parser.state == .ground and self.utf8decoder.state == 0;
|
||||
|
||||
Reference in New Issue
Block a user