mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
libghostty-vt: add C API for snapshotting functions
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
size_t continuation_limit = 1024;
assert(ghostty_terminal_set(
terminal,
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
&continuation_limit) == GHOSTTY_SUCCESS);
uint8_t *bytes = NULL;
size_t len = 0;
assert(ghostty_snapshot_encode_alloc(
terminal, NULL, &bytes, &len) == GHOSTTY_SUCCESS);
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
GhosttyReader reader = {
.read = read_snapshot,
.userdata = source,
};
GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new(
NULL, &decoder, reader) == GHOSTTY_SUCCESS);
GhosttyTerminal terminal = NULL;
assert(ghostty_snapshot_decoder_ready(
decoder, &terminal) == GHOSTTY_SUCCESS);
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:
@@ -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