Files
ghostty/src/terminal/c/terminal.zig
Mitchell Hashimoto cfc19e8053 libghostty: add configurable mode defaults, remove mode_set/get
ABI BREAKING: This removes `ghostty_terminal_mode_get` and `_mode_set`.
We can now represent these operations completely with standard 
`ghostty_terminal_get` and `ghostty_terminal_set`, which makes it much
more flexible to preserve ABI in the future.

This is all centered around a new `GhosttyTerminalModeConfig` structure
that is an in or out parameter depending on use case.

This also adds a new `GHOSTTY_TERMINAL_OPT_MODE_DEFAULT` option that
can be used to set the _default_ value of mode that happens when a RIS
event (full reset) is sent.
2026-08-05 22:07:25 -07:00

4976 lines
163 KiB
Zig

const builtin = @import("builtin");
const std = @import("std");
const testing = std.testing;
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
pub const ZigTerminal = @import("../Terminal.zig");
const Action = @import("../stream.zig").Action;
const osc = @import("../osc.zig");
const Stream = @import("../stream_terminal.zig").Stream;
const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const apc = @import("../apc.zig");
const kitty = @import("../kitty/key.zig");
const kitty_gfx_c = @import("kitty_graphics.zig");
const modes = @import("../modes.zig");
const point = @import("../point.zig");
const size = @import("../size.zig");
const device_attributes = @import("../device_attributes.zig");
const device_status = @import("../device_status.zig");
const size_report = @import("../size_report.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const grid_ref_c = @import("grid_ref.zig");
const grid_ref_tracked_c = @import("grid_ref_tracked.zig");
const selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
const clipboard = @import("../clipboard.zig");
const c_io = @import("io.zig");
const snapshot_core = @import("../snapshot/main.zig");
const Result = @import("result.zig").Result;
const assert = @import("../../quirks.zig").inlineAssert;
const Handler = @import("../stream_terminal.zig").Handler;
const max_path_bytes = if (builtin.os.tag == .freestanding) 4096 else std.fs.max_path_bytes;
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 {
terminal: *ZigTerminal,
/// 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,
stream: Stream,
effects: Effects = .{},
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
/// Fetches a `TerminalWrapper` reference from a `Handler`.
fn fromHandler(handler: *Handler) *TerminalWrapper {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
return @alignCast(@fieldParentPtr("stream", stream_ptr));
}
};
/// A single MIME representation in a clipboard write.
///
/// C: GhosttyClipboardContent
pub const ClipboardContent = extern struct {
mime: lib.String,
data: lib.String,
};
/// A protocol-neutral request to replace or clear clipboard contents.
///
/// C: GhosttyClipboardWrite
pub const ClipboardWrite = extern struct {
size: usize,
location: clipboard.Location,
contents: ?[*]const ClipboardContent,
contents_len: usize,
};
/// A request to show a desktop notification.
///
/// C: GhosttyTerminalDesktopNotification
pub const DesktopNotification = extern struct {
size: usize,
title: lib.String,
body: lib.String,
};
/// C: GhosttyTerminalProgressState
pub const ProgressState = osc.Command.ProgressReport.State;
/// A progress report emitted by the running program.
///
/// C: GhosttyTerminalProgressReport
pub const ProgressReport = extern struct {
size: usize,
state: ProgressState,
progress: i8,
};
/// A terminal mode and boolean value used for mode configuration.
///
/// C: GhosttyTerminalModeConfig
pub const ModeConfig = extern struct {
mode: modes.ModeTag.Backing,
value: bool,
fn toMode(self: ModeConfig) ?modes.Mode {
const tag: modes.ModeTag = @bitCast(self.mode);
return modes.modeFromInt(tag.value, tag.ansi);
}
};
/// C callback state for terminal effects. Trampolines are always
/// installed on the stream handler; they check these fields and
/// no-op when the corresponding callback is null.
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
bell: ?BellFn = null,
color_scheme: ?ColorSchemeFn = null,
desktop_notification: ?DesktopNotificationFn = null,
device_attributes_cb: ?DeviceAttributesFn = null,
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
title_changed: ?TitleChangedFn = null,
pwd_changed: ?PwdChangedFn = null,
progress_report: ?ProgressReportFn = null,
size_cb: ?SizeFn = null,
clipboard_write: ?ClipboardWriteFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
/// trampoline converts C feature codes into this buffer and returns
/// a slice pointing into it. Storing it here ensures the slice
/// remains valid after the trampoline returns, since the caller
/// (`reportDeviceAttributes`) reads it before any re-entrant call.
da_features_buf: [64]device_attributes.Primary.Feature = undefined,
/// C function pointer type for the write_pty callback.
pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void;
/// C function pointer type for the bell callback.
pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
/// C function pointer type for the color_scheme callback.
/// Returns true and fills out_scheme if a color scheme is available,
/// or returns false to silently ignore the query.
pub const ColorSchemeFn = *const fn (Terminal, ?*anyopaque, *device_status.ColorScheme) callconv(lib.calling_conv) bool;
/// C function pointer type for the enquiry callback.
/// Returns the response bytes. The memory must remain valid
/// until the callback returns.
pub const EnquiryFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
/// C function pointer type for the xtversion callback.
/// Returns the version string (e.g. "ghostty 1.2.3"). The memory
/// must remain valid until the callback returns. An empty string
/// (len=0) causes the default "libghostty" to be reported.
pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
/// C function pointer type for the clipboard_write callback. The request
/// and its contents are borrowed and only valid for the callback duration.
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
/// C function pointer type for the desktop_notification callback. The
/// request and its strings are borrowed for the callback duration.
pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void;
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
/// C function pointer type for the pwd_changed callback.
pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
/// C function pointer type for the progress_report callback.
pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void;
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(lib.calling_conv) bool;
/// C function pointer type for the device_attributes callback.
/// Returns true and fills out_attrs if attributes are available,
/// or returns false to silently ignore the query.
pub const DeviceAttributesFn = *const fn (Terminal, ?*anyopaque, *CDeviceAttributes) callconv(lib.calling_conv) bool;
/// C-compatible device attributes struct.
/// C: GhosttyDeviceAttributes
pub const CDeviceAttributes = extern struct {
primary: Primary,
secondary: Secondary,
tertiary: Tertiary,
pub const Primary = extern struct {
conformance_level: u16,
features: [64]u16,
num_features: usize,
};
pub const Secondary = extern struct {
device_type: u16,
firmware_version: u16,
rom_cartridge: u16,
};
pub const Tertiary = extern struct {
unit_id: u32,
};
};
fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.write_pty orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len);
}
fn bellTrampoline(handler: *Handler) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.bell orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.clipboard_write orelse return .unsupported;
// Most protocols currently produce one representation, so keep that
// path allocation-free while supporting arbitrary multi-MIME writes.
var stack_contents: [4]ClipboardContent = undefined;
const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len)
stack_contents[0..write.contents.len]
else
wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch
return .io_error;
defer if (write.contents.len > stack_contents.len)
wrapper.terminal.gpa().free(contents);
for (contents, write.contents) |*c_content, content| {
c_content.* = .{
.mime = .{
.ptr = content.mime.ptr,
.len = content.mime.len,
},
.data = .{
.ptr = content.data.ptr,
.len = content.data.len,
},
};
}
const request: ClipboardWrite = .{
.size = @sizeOf(ClipboardWrite),
.location = write.location,
.contents = if (contents.len > 0) contents.ptr else null,
.contents_len = contents.len,
};
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn desktopNotificationTrampoline(
handler: *Handler,
notification: Action.ShowDesktopNotification,
) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.desktop_notification orelse return;
const request: DesktopNotification = .{
.size = @sizeOf(DesktopNotification),
.title = .{
.ptr = notification.title.ptr,
.len = notification.title.len,
},
.body = .{
.ptr = notification.body.ptr,
.len = notification.body.len,
},
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.color_scheme orelse return null;
var scheme: device_status.ColorScheme = undefined;
if (func(@ptrCast(wrapper), wrapper.effects.userdata, &scheme)) return scheme;
return null;
}
fn deviceAttributesTrampoline(handler: *Handler) device_attributes.Attributes {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.device_attributes_cb orelse return .{};
// Get our attributes from the callback.
var c_attrs: CDeviceAttributes = undefined;
if (!func(@ptrCast(wrapper), wrapper.effects.userdata, &c_attrs)) return .{};
// Note below we use a lot of enumFromInt but its always safe
// because all our types are non-exhaustive enums.
const n: usize = @min(c_attrs.primary.num_features, 64);
for (0..n) |i| wrapper.effects.da_features_buf[i] = @enumFromInt(c_attrs.primary.features[i]);
return .{
.primary = .{
.conformance_level = @enumFromInt(c_attrs.primary.conformance_level),
.features = wrapper.effects.da_features_buf[0..n],
},
.secondary = .{
.device_type = @enumFromInt(c_attrs.secondary.device_type),
.firmware_version = c_attrs.secondary.firmware_version,
.rom_cartridge = c_attrs.secondary.rom_cartridge,
},
.tertiary = .{
.unit_id = c_attrs.tertiary.unit_id,
},
};
}
fn enquiryTrampoline(handler: *Handler) []const u8 {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.enquiry orelse return "";
const result = func(@ptrCast(wrapper), wrapper.effects.userdata);
if (result.len == 0) return "";
return result.ptr[0..result.len];
}
fn xtversionTrampoline(handler: *Handler) []const u8 {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.xtversion orelse return "";
const result = func(@ptrCast(wrapper), wrapper.effects.userdata);
if (result.len == 0) return "";
return result.ptr[0..result.len];
}
fn titleChangedTrampoline(handler: *Handler) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.title_changed orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn pwdChangedTrampoline(handler: *Handler) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.pwd_changed orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn progressReportTrampoline(
handler: *Handler,
report: osc.Command.ProgressReport,
) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.progress_report orelse return;
const c_report: ProgressReport = .{
.size = @sizeOf(ProgressReport),
.state = @enumFromInt(@intFromEnum(report.state)),
.progress = if (report.progress) |value| @intCast(value) else -1,
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report);
}
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.size_cb orelse return null;
var s: size_report.Size = undefined;
if (func(@ptrCast(wrapper), wrapper.effects.userdata, &s)) return s;
return null;
}
};
/// C: GhosttyTerminal
pub const Terminal = ?*TerminalWrapper;
/// C: GhosttyTerminalCompressionMode
pub const CompressionMode = ZigTerminal.CompressionMode;
/// C: GhosttyTerminalCompressionResult
pub const CompressionResult = ZigTerminal.CompressionResult;
pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
return (terminal_ orelse return null).terminal;
}
/// 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,
};
pub fn new(
alloc_: ?*const CAllocator,
result: *Terminal,
cols: size.CellCountInt,
rows: size.CellCountInt,
) callconv(lib.calling_conv) Result {
result.* = new_(alloc_, cols, rows) catch |err| {
result.* = null;
return switch (err) {
error.InvalidValue => .invalid_value,
error.OutOfMemory => .out_of_memory,
};
};
return .success;
}
fn new_(
alloc_: ?*const CAllocator,
cols: size.CellCountInt,
rows: size.CellCountInt,
) NewError!Terminal {
if (cols == 0 or rows == 0) return error.InvalidValue;
const alloc = lib.alloc.default(alloc_);
const t = alloc.create(ZigTerminal) catch
return error.OutOfMemory;
errdefer alloc.destroy(t);
const io = try Io.init(alloc);
errdefer io.deinit(alloc);
// Setup our terminal
t.* = try .init(
io.io(),
alloc,
.{
.cols = cols,
.rows = rows,
},
);
errdefer t.deinit(alloc);
// libghostty-vt embedders don't necessarily install Ghostty's shell
// integration, so don't assume OSC 133 prompts can be redrawn on resize.
// Shells can still opt in with OSC 133;A;redraw=1.
t.flags.shell_redraws_prompt = .false;
return try wrap(
alloc,
t,
io,
default_continuation_max_bytes,
);
}
pub fn vt_write(
terminal_: Terminal,
ptr: [*]const u8,
len: usize,
) callconv(lib.calling_conv) void {
const wrapper = terminal_ orelse return;
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,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
const out_activity = out_activity_ orelse return .invalid_value;
out_activity.* = t.compressionActivity();
return .success;
}
pub fn compress(
terminal_: Terminal,
mode_: c_int,
out_result_: ?*CompressionResult,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
const out_result = out_result_ orelse return .invalid_value;
const mode = std.enums.fromInt(CompressionMode, mode_) orelse return .invalid_value;
out_result.* = t.compress(mode);
return .success;
}
/// C: GhosttyTerminalOption
pub const Option = enum(c_int) {
userdata = 0,
write_pty = 1,
bell = 2,
enquiry = 3,
xtversion = 4,
title_changed = 5,
size_cb = 6,
color_scheme = 7,
device_attributes = 8,
title = 9,
pwd = 10,
color_foreground = 11,
color_background = 12,
color_cursor = 13,
color_palette = 14,
kitty_image_storage_limit = 15,
kitty_image_medium_file = 16,
kitty_image_medium_temp_file = 17,
kitty_image_medium_shared_mem = 18,
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
selection = 21,
default_cursor_style = 22,
default_cursor_blink = 23,
glyph_protocol = 24,
pwd_changed = 25,
clipboard_write = 26,
scrollback_max_bytes = 27,
scrollback_max_lines = 28,
desktop_notification = 29,
progress_report = 30,
continuation_max_bytes = 31,
title_report = 32,
mode_default = 33,
mode = 34,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.write_pty => ?Effects.WritePtyFn,
.bell => ?Effects.BellFn,
.color_scheme => ?Effects.ColorSchemeFn,
.desktop_notification => ?Effects.DesktopNotificationFn,
.device_attributes => ?Effects.DeviceAttributesFn,
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
.title_changed => ?Effects.TitleChangedFn,
.pwd_changed => ?Effects.PwdChangedFn,
.progress_report => ?Effects.ProgressReportFn,
.size_cb => ?Effects.SizeFn,
.clipboard_write => ?Effects.ClipboardWriteFn,
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
.kitty_image_storage_limit => ?*const u64,
.kitty_image_medium_file,
.kitty_image_medium_shared_mem,
.glyph_protocol,
.title_report,
=> ?*const bool,
.kitty_image_medium_temp_file => ?*const lib.String,
.apc_max_bytes,
.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,
.default_cursor_blink => ?*const bool,
.mode, .mode_default => ?*const ModeConfig,
};
}
};
pub fn set(
terminal_: Terminal,
option: Option,
value: ?*const anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(Option, @intFromEnum(option)) orelse {
log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)});
return .invalid_value;
};
}
const wrapper = terminal_ orelse return .invalid_value;
return switch (option) {
inline else => |comptime_option| setTyped(
wrapper,
comptime_option,
@ptrCast(@alignCast(value)),
),
};
}
fn setTyped(
wrapper: *TerminalWrapper,
comptime option: Option,
value: option.InType(),
) Result {
switch (option) {
.userdata => wrapper.effects.userdata = @constCast(value),
.write_pty => wrapper.effects.write_pty = value,
.bell => wrapper.effects.bell = value,
.color_scheme => wrapper.effects.color_scheme = value,
.desktop_notification => wrapper.effects.desktop_notification = value,
.device_attributes => wrapper.effects.device_attributes_cb = value,
.enquiry => wrapper.effects.enquiry = value,
.xtversion => wrapper.effects.xtversion = value,
.title_changed => wrapper.effects.title_changed = value,
.pwd_changed => wrapper.effects.pwd_changed = value,
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
.clipboard_write => wrapper.effects.clipboard_write = value,
.title_report => wrapper.stream.handler.title_report = if (value) |ptr|
ptr.*
else
false,
.title => {
const str = if (value) |v| v.ptr[0..v.len] else "";
wrapper.terminal.setTitle(str) catch return .out_of_memory;
},
.pwd => {
const str = if (value) |v| v.ptr[0..v.len] else "";
wrapper.terminal.setPwd(str) catch return .out_of_memory;
},
.color_foreground => {
wrapper.terminal.colors.foreground.default = if (value) |v| .fromC(v.*) else null;
wrapper.terminal.flags.dirty.palette = true;
},
.color_background => {
wrapper.terminal.colors.background.default = if (value) |v| .fromC(v.*) else null;
wrapper.terminal.flags.dirty.palette = true;
},
.color_cursor => {
wrapper.terminal.colors.cursor.default = if (value) |v| .fromC(v.*) else null;
wrapper.terminal.flags.dirty.palette = true;
},
.color_palette => {
wrapper.terminal.colors.palette.changeDefault(
if (value) |v| color.paletteZval(v) else color.default,
);
wrapper.terminal.flags.dirty.palette = true;
},
.kitty_image_storage_limit => {
if (comptime !build_options.kitty_graphics) return .success;
const limit: usize = if (value) |v| @intCast(v.*) else 0;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit);
}
},
.kitty_image_medium_file,
.kitty_image_medium_shared_mem,
=> {
if (comptime !build_options.kitty_graphics) return .success;
const val = (value orelse return .success).*;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
switch (option) {
.kitty_image_medium_file => screen.kitty_images.image_limits.file = val,
.kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val,
else => unreachable,
}
}
},
.kitty_image_medium_temp_file => {
if (comptime !build_options.kitty_graphics) return .success;
if (value) |v| {
if (v.len > wrapper.tmp_dir_path.len) return .out_of_memory;
@memcpy(wrapper.tmp_dir_path[0..v.len], v.ptr[0..v.len]);
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.image_limits.temporary_file = .{
.enabled = .{ .directory = wrapper.tmp_dir_path[0..v.len] },
};
}
} else {
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.image_limits.temporary_file = .disabled;
}
}
},
.apc_max_bytes => {
wrapper.stream.handler.apc_handler.max_bytes = if (value) |ptr|
.initFull(ptr.*)
else
.{};
},
.apc_max_bytes_kitty => {
if (value) |ptr| {
wrapper.stream.handler.apc_handler.max_bytes.put(.kitty, ptr.*);
} else {
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
.glyph_protocol => {
const enabled = (value orelse return .success).*;
wrapper.stream.handler.apc_handler.enable(.glyph, enabled);
if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa());
},
.selection => {
if (value) |ptr| {
const sel = ptr.toZig() orelse return .invalid_value;
wrapper.terminal.screens.active.select(sel) catch return .out_of_memory;
} else {
wrapper.terminal.screens.active.clearSelection();
}
},
.default_cursor_style => {
const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value;
wrapper.terminal.setDefaultCursorStyle(style);
},
.default_cursor_blink => {
const blink = if (value) |ptr| ptr.* else false;
wrapper.terminal.setDefaultCursorBlink(blink);
},
.scrollback_max_bytes => wrapper.terminal.setScrollbackMaxBytes(
if (value) |ptr| ptr.* else null,
),
.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,
),
.mode, .mode_default => {
const config = (value orelse return .invalid_value).*;
const mode = config.toMode() orelse return .invalid_value;
switch (option) {
.mode => wrapper.terminal.modes.set(mode, config.value),
.mode_default => {
if (!modes.defaultConfigurable(mode)) return .invalid_value;
wrapper.terminal.modes.setDefault(mode, config.value);
},
else => unreachable,
}
},
}
return .success;
}
/// C: GhosttyTerminalCursorStyle
pub const TerminalCursorStyle = enum(c_int) {
bar = 0,
block = 1,
underline = 2,
block_hollow = 3,
_,
fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle {
return switch (self) {
.bar => .bar,
.block => .block,
.underline => .underline,
.block_hollow => .block_hollow,
_ => null,
};
}
};
/// C: GhosttyDeviceAttributes
pub const DeviceAttributes = Effects.CDeviceAttributes;
/// C: GhosttyTerminalScrollViewport
pub const ScrollViewport = ZigTerminal.ScrollViewport.C;
pub fn scroll_viewport(
terminal_: Terminal,
behavior: ScrollViewport,
) callconv(lib.calling_conv) void {
const t: *ZigTerminal = (terminal_ orelse return).terminal;
t.scrollViewport(switch (behavior.tag) {
.top => .top,
.bottom => .bottom,
.delta => .{ .delta = behavior.value.delta },
.row => .{ .row = behavior.value.row },
});
}
pub fn resize(
terminal_: Terminal,
cols: size.CellCountInt,
rows: size.CellCountInt,
cell_width_px: u32,
cell_height_px: u32,
) callconv(lib.calling_conv) Result {
const wrapper = terminal_ orelse return .invalid_value;
wrapper.stream.handler.resize(.{
.cols = cols,
.rows = rows,
.cell_size_px = .{
.width = cell_width_px,
.height = cell_height_px,
},
}) catch |err| return switch (err) {
error.InvalidValue => .invalid_value,
error.OutOfMemory => .out_of_memory,
};
return .success;
}
pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void {
const t: *ZigTerminal = (terminal_ orelse return).terminal;
t.fullReset();
}
/// C: GhosttyKittyGraphics
pub const KittyGraphics = kitty_gfx_c.KittyGraphics;
/// C: GhosttyTerminalScreen
pub const TerminalScreen = ScreenSet.Key;
/// C: GhosttyTerminalScrollbar
pub const TerminalScrollbar = PageList.Scrollbar.C;
/// C: GhosttyTerminalData
pub const TerminalData = enum(c_int) {
invalid = 0,
cols = 1,
rows = 2,
cursor_x = 3,
cursor_y = 4,
cursor_pending_wrap = 5,
active_screen = 6,
cursor_visible = 7,
kitty_keyboard_flags = 8,
scrollbar = 9,
cursor_style = 10,
mouse_tracking = 11,
title = 12,
pwd = 13,
total_rows = 14,
scrollback_rows = 15,
width_px = 16,
height_px = 17,
color_foreground = 18,
color_background = 19,
color_cursor = 20,
color_palette = 21,
color_foreground_default = 22,
color_background_default = 23,
color_cursor_default = 24,
color_palette_default = 25,
kitty_image_storage_limit = 26,
kitty_image_medium_file = 27,
kitty_image_medium_temp_file = 28,
kitty_image_medium_shared_mem = 29,
kitty_graphics = 30,
selection = 31,
viewport_active = 32,
vt_processing_error = 33,
scrollback_max_bytes = 34,
scrollback_max_lines = 35,
continuation_max_bytes = 36,
mode = 37,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
return switch (self) {
.invalid => void,
.cols, .rows, .cursor_x, .cursor_y => size.CellCountInt,
.cursor_pending_wrap,
.cursor_visible,
.mouse_tracking,
.viewport_active,
.vt_processing_error,
=> bool,
.active_screen => TerminalScreen,
.kitty_keyboard_flags => u8,
.scrollbar => TerminalScrollbar,
.cursor_style => style_c.Style,
.title, .pwd => lib.String,
.total_rows,
.scrollback_rows,
.scrollback_max_bytes,
.scrollback_max_lines,
.continuation_max_bytes,
=> usize,
.width_px, .height_px => u32,
.color_foreground,
.color_background,
.color_cursor,
.color_foreground_default,
.color_background_default,
.color_cursor_default,
=> color.RGB.C,
.color_palette, .color_palette_default => color.PaletteC,
.kitty_image_storage_limit => u64,
.kitty_image_medium_file,
.kitty_image_medium_shared_mem,
=> bool,
.kitty_image_medium_temp_file => lib.String,
.kitty_graphics => KittyGraphics,
.selection => selection_c.CSelection,
.mode => ModeConfig,
};
}
};
pub fn get(
terminal_: Terminal,
data: TerminalData,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(TerminalData, @intFromEnum(data)) orelse {
log.warn("terminal_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
const out_ptr = out orelse return .invalid_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| getTyped(
terminal_,
comptime_data,
@ptrCast(@alignCast(out_ptr)),
),
};
}
pub fn get_multi(
terminal_: Terminal,
count: usize,
keys: ?[*]const TerminalData,
values: ?[*]?*anyopaque,
out_written: ?*usize,
) callconv(lib.calling_conv) Result {
const k = keys orelse return .invalid_value;
const v = values orelse return .invalid_value;
for (0..count) |i| {
const result = get(terminal_, k[i], v[i]);
if (result != .success) {
if (out_written) |w| w.* = i;
return result;
}
}
if (out_written) |w| w.* = count;
return .success;
}
fn getTyped(
terminal_: Terminal,
comptime data: TerminalData,
out: *data.OutType(),
) Result {
const wrapper = terminal_ orelse return .invalid_value;
const t: *ZigTerminal = wrapper.terminal;
switch (data) {
.invalid => return .invalid_value,
.cols => out.* = t.cols,
.rows => out.* = t.rows,
.cursor_x => out.* = t.screens.active.cursor.x,
.cursor_y => out.* = t.screens.active.cursor.y,
.cursor_pending_wrap => out.* = t.screens.active.cursor.pending_wrap,
.active_screen => out.* = t.screens.active_key,
.cursor_visible => out.* = t.modes.get(.cursor_visible),
.kitty_keyboard_flags => out.* = @as(u8, t.screens.active.kitty_keyboard.current().int()),
.scrollbar => out.* = t.screens.active.pages.scrollbar().cval(),
.cursor_style => out.* = .fromStyle(t.screens.active.cursor.style),
.mouse_tracking => out.* = t.modes.get(.mouse_event_x10) or
t.modes.get(.mouse_event_normal) or
t.modes.get(.mouse_event_button) or
t.modes.get(.mouse_event_any),
.title => {
const title = t.getTitle() orelse "";
out.* = .{ .ptr = title.ptr, .len = title.len };
},
.pwd => {
const pwd = t.getPwd() orelse "";
out.* = .{ .ptr = pwd.ptr, .len = pwd.len };
},
.total_rows => out.* = t.screens.active.pages.total_rows,
.scrollback_rows => out.* = t.screens.active.pages.total_rows - t.rows,
.width_px => out.* = t.width_px,
.height_px => out.* = t.height_px,
.color_foreground => out.* = (t.colors.foreground.get() orelse return .no_value).cval(),
.color_background => out.* = (t.colors.background.get() orelse return .no_value).cval(),
.color_cursor => out.* = (t.colors.cursor.get() orelse return .no_value).cval(),
.color_foreground_default => out.* = (t.colors.foreground.default orelse return .no_value).cval(),
.color_background_default => out.* = (t.colors.background.default orelse return .no_value).cval(),
.color_cursor_default => out.* = (t.colors.cursor.default orelse return .no_value).cval(),
.color_palette => out.* = color.paletteCval(&t.colors.palette.current),
.color_palette_default => out.* = color.paletteCval(&t.colors.palette.original),
.kitty_image_storage_limit => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = @intCast(t.screens.active.kitty_images.total_limit);
},
.kitty_image_medium_file => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = t.screens.active.kitty_images.image_limits.file;
},
.kitty_image_medium_temp_file => {
if (comptime !build_options.kitty_graphics) return .no_value;
const dir = switch (t.screens.active.kitty_images.image_limits.temporary_file) {
.enabled => |d| d.directory,
.disabled => "",
};
out.* = .{ .ptr = dir.ptr, .len = dir.len };
},
.kitty_image_medium_shared_mem => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = t.screens.active.kitty_images.image_limits.shared_memory;
},
.kitty_graphics => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = &t.screens.active.kitty_images;
},
.selection => out.* = selection_c.CSelection.fromZig(
t.screens.active.selection orelse return .no_value,
),
.viewport_active => out.* = t.screens.active.pages.viewport == .active,
.vt_processing_error => out.* = wrapper.stream.handler.semantic_failure,
.scrollback_max_bytes => {
const max = t.screens.get(.primary).?.pages.limits.bytes.explicit;
if (max == std.math.maxInt(usize)) return .no_value;
out.* = max;
},
.scrollback_max_lines => {
const max = t.screens.get(.primary).?.pages.limits.lines.explicit;
if (max == std.math.maxInt(usize)) return .no_value;
out.* = max;
},
.continuation_max_bytes => out.* = continuationMaxBytes(wrapper),
.mode => {
const mode = out.toMode() orelse return .invalid_value;
out.value = t.modes.get(mode);
},
}
return .success;
}
pub fn grid_ref(
terminal_: Terminal,
pt: point.Point.C,
out_ref: ?*grid_ref_c.CGridRef,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
const zig_pt: point.Point = .fromC(pt);
const p = t.screens.active.pages.pin(zig_pt) orelse
return .invalid_value;
if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p);
return .success;
}
pub fn grid_ref_track(
terminal_: Terminal,
pt: point.Point.C,
out_ref: ?*grid_ref_tracked_c.CTrackedGridRef,
) callconv(lib.calling_conv) Result {
const wrapper = terminal_ orelse return .invalid_value;
const out = out_ref orelse return .invalid_value;
out.* = null;
const t: *ZigTerminal = wrapper.terminal;
const list = &t.screens.active.pages;
const p = list.pin(.fromC(pt)) orelse return .invalid_value;
const tracked_pin = list.trackPin(p) catch return .out_of_memory;
const alloc = t.gpa();
const ref = alloc.create(grid_ref_tracked_c.TrackedGridRef) catch {
list.untrackPin(tracked_pin);
return .out_of_memory;
};
ref.* = .{
.alloc = alloc,
.terminal = wrapper,
.screen_key = t.screens.active_key,
.screen_generation = t.screens.generation(t.screens.active_key),
.pin = tracked_pin,
};
// Store the tracked ref in the terminal so that when we free
// the terminal the tracked ref can be detached safely.
wrapper.tracked_grid_refs.putNoClobber(
alloc,
ref,
{},
) catch {
list.untrackPin(tracked_pin);
alloc.destroy(ref);
return .out_of_memory;
};
out.* = ref;
return .success;
}
pub fn point_from_grid_ref(
terminal_: Terminal,
ref: *const grid_ref_c.CGridRef,
tag: point.Tag,
out: ?*point.Coordinate,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
const p = ref.toPin() orelse return .invalid_value;
const pt = t.screens.active.pages.pointFromPin(tag, p) orelse
return .no_value;
if (out) |o| o.* = pt.coord();
return .success;
}
pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void {
const wrapper = terminal_ orelse return;
const t = wrapper.terminal;
const alloc = t.gpa();
for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null;
wrapper.tracked_grid_refs.deinit(alloc);
wrapper.stream.deinit();
t.deinit(alloc);
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(
&lib.alloc.test_allocator,
&t,
80,
24,
));
try testing.expect(t != null);
free(t);
}
test "new invalid value" {
var t: Terminal = null;
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&t,
0,
24,
));
try testing.expect(t == null);
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&t,
80,
0,
));
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(
&lib.alloc.test_allocator,
&t,
80,
3,
));
defer free(t);
const primary = t.?.terminal.screens.get(.primary).?;
const max_bytes: usize = 0;
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_bytes, &max_bytes),
);
try testing.expectEqual(
@as(usize, 0),
primary.pages.limits.bytes.explicit,
);
try testing.expect(primary.no_scrollback);
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_bytes, null),
);
try testing.expectEqual(
std.math.maxInt(usize),
primary.pages.limits.bytes.explicit,
);
try testing.expect(!primary.no_scrollback);
const max_lines: usize = 12;
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_lines, &max_lines),
);
try testing.expectEqual(
max_lines,
primary.pages.limits.lines.explicit,
);
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_lines, null),
);
try testing.expectEqual(
std.math.maxInt(usize),
primary.pages.limits.lines.explicit,
);
}
test "free null" {
free(null);
}
test "scroll_viewport" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
5,
2,
));
defer free(t);
const zt = t.?.terminal;
var viewport_active: bool = false;
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
// Write "hello" on the first line
vt_write(t, "hello", 5);
// Push "hello" into scrollback with 3 newlines (index = ESC D)
vt_write(t, "\x1bD\x1bD\x1bD", 6);
{
// Viewport should be empty now since hello scrolled off
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
// Scroll to top: "hello" should be visible again
scroll_viewport(t, .{ .tag = .top, .value = undefined });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(!viewport_active);
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("hello", str);
}
// Scroll to bottom: viewport should be empty again
scroll_viewport(t, .{ .tag = .bottom, .value = undefined });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
// Scroll up by delta to bring "hello" back into view
scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(!viewport_active);
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("hello", str);
}
}
test "scroll_viewport row" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
5,
2,
));
defer free(t);
const zt = t.?.terminal;
// Write 4 rows so that rows "1" and "2" are pushed into scrollback:
// total rows is 4, viewport length is 2.
vt_write(t, "1\r\n2\r\n3\r\n4", 10);
var viewport_active: bool = false;
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
// Row 0 is the top of the scrollback.
scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(!viewport_active);
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("1\n2", str);
}
// An absolute row within the scrollback becomes the first visible
// row and round-trips through the scrollbar offset.
scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } });
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("2\n3", str);
}
var scrollbar_data: TerminalScrollbar = undefined;
try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
try testing.expectEqual(@as(u64, 4), scrollbar_data.total);
try testing.expectEqual(@as(u64, 1), scrollbar_data.offset);
try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
// A row past the end clamps to the active area.
scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
{
const str = try zt.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("3\n4", str);
}
try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
try testing.expectEqual(@as(u64, 2), scrollbar_data.offset);
}
test "scroll_viewport row alt screen" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
5,
2,
));
defer free(t);
// Enter the alternate screen, which has no scrollback.
vt_write(t, "\x1b[?1049h", 8);
var screen: TerminalScreen = undefined;
try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen)));
try testing.expectEqual(TerminalScreen.alternate, screen);
// Scrolling to any row keeps the viewport on the active area.
var viewport_active: bool = false;
scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
try testing.expect(viewport_active);
// With no scrollback the scrollbar covers exactly the active area.
var scrollbar_data: TerminalScrollbar = undefined;
try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
try testing.expectEqual(@as(u64, 2), scrollbar_data.total);
try testing.expectEqual(@as(u64, 0), scrollbar_data.offset);
try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
}
test "scroll_viewport null" {
scroll_viewport(null, .{ .tag = .top, .value = undefined });
scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } });
}
test "compression invalid arguments" {
var activity: u64 = undefined;
var compression_result: CompressionResult = undefined;
try testing.expectEqual(
Result.invalid_value,
compression_activity(null, &activity),
);
try testing.expectEqual(
Result.invalid_value,
compress(null, @intFromEnum(CompressionMode.incremental), &compression_result),
);
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(
Result.invalid_value,
compression_activity(t, null),
);
try testing.expectEqual(
Result.invalid_value,
compress(t, @intFromEnum(CompressionMode.incremental), null),
);
try testing.expectEqual(
Result.invalid_value,
compress(t, -1, &compression_result),
);
}
test "compression activity and incremental scheduling" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var initial_activity: u64 = undefined;
try testing.expectEqual(
Result.success,
compression_activity(t, &initial_activity),
);
const line = "repeated and compressible terminal history\r\n";
const repeat = 4_000;
const input = try testing.allocator.alloc(u8, line.len * repeat);
defer testing.allocator.free(input);
for (0..repeat) |i|
@memcpy(input[i * line.len ..][0..line.len], line);
vt_write(t, input.ptr, input.len);
var activity: u64 = undefined;
try testing.expectEqual(
Result.success,
compression_activity(t, &activity),
);
try testing.expect(activity != initial_activity);
var compression_result: CompressionResult = undefined;
for (0..1_000) |_| {
try testing.expectEqual(
Result.success,
compress(
t,
@intFromEnum(CompressionMode.incremental),
&compression_result,
),
);
switch (compression_result) {
.pending => continue,
.complete => break,
.unsupported => unreachable,
}
} else return error.TestUnexpectedResult;
// Compression changes storage representation, not the activity token.
var final_activity: u64 = undefined;
try testing.expectEqual(
Result.success,
compression_activity(t, &final_activity),
);
try testing.expectEqual(activity, final_activity);
try testing.expectEqual(
Result.success,
compress(t, @intFromEnum(CompressionMode.full), &compression_result),
);
try testing.expectEqual(CompressionResult.complete, compression_result);
}
test "reset" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
reset(t);
const str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
test "reset null" {
reset(null);
}
test "resize" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.success, resize(t, 40, 12, 9, 18));
try testing.expectEqual(40, t.?.terminal.cols);
try testing.expectEqual(12, t.?.terminal.rows);
}
test "resize null" {
try testing.expectEqual(Result.invalid_value, resize(null, 80, 24, 9, 18));
}
test "resize invalid value" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 0, 24, 9, 18));
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
}
test "resize shrinks both axes with cursor at bottom" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// CSI 24;1H -> park the cursor on the bottom row (1-based).
const move = "\x1b[24;1H";
vt_write(t, move, move.len);
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
// Previously this underflowed in PageList.resizeCols.
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
try testing.expectEqual(79, t.?.terminal.cols);
try testing.expectEqual(23, t.?.terminal.rows);
}
test "set and get mode" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// DEC mode 25 (cursor_visible) defaults to true
const cursor_visible: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false });
var config: ModeConfig = .{ .mode = cursor_visible, .value = undefined };
try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config)));
try testing.expect(config.value);
// Set it to false
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config)));
try testing.expect(!config.value);
// ANSI mode 4 (insert) defaults to false
const insert: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true });
config.mode = insert;
try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config)));
try testing.expect(!config.value);
config.value = true;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config)));
try testing.expect(config.value);
}
test "set mode default updates current and reset value" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{
.value = 2027,
.ansi = false,
});
var config: ModeConfig = .{ .mode = tag, .value = true };
try testing.expectEqual(Result.success, set(
t,
.mode_default,
@ptrCast(&config),
));
try testing.expect(t.?.terminal.modes.get(.grapheme_cluster));
try testing.expect(t.?.terminal.modes.default.grapheme_cluster);
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expect(!t.?.terminal.modes.get(.grapheme_cluster));
try testing.expect(t.?.terminal.modes.default.grapheme_cluster);
// Setting the default also unconditionally replaces the current value.
config.value = true;
try testing.expectEqual(Result.success, set(
t,
.mode_default,
@ptrCast(&config),
));
try testing.expect(t.?.terminal.modes.get(.grapheme_cluster));
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
vt_write(t, "\x1bc", 2);
try testing.expect(t.?.terminal.modes.get(.grapheme_cluster));
config.value = false;
try testing.expectEqual(Result.success, set(
t,
.mode_default,
@ptrCast(&config),
));
try testing.expect(!t.?.terminal.modes.get(.grapheme_cluster));
try testing.expect(!t.?.terminal.modes.default.grapheme_cluster);
}
test "set mode default validates configuration" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, set(
t,
.mode_default,
null,
));
var config: ModeConfig = .{
.mode = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.invalid_value, set(
t,
.mode_default,
@ptrCast(&config),
));
config.mode = @bitCast(modes.ModeTag{ .value = 1047, .ansi = false });
try testing.expectEqual(Result.invalid_value, set(
t,
.mode_default,
@ptrCast(&config),
));
}
test "set and get mode null" {
const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false });
var config: ModeConfig = .{ .mode = tag, .value = true };
try testing.expectEqual(Result.invalid_value, set(null, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.invalid_value, get(null, .mode, @ptrCast(&config)));
}
test "set and get mode validate configuration" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, set(t, .mode, null));
try testing.expectEqual(Result.invalid_value, get(t, .mode, null));
var config: ModeConfig = .{
.mode = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.invalid_value, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.invalid_value, get(t, .mode, @ptrCast(&config)));
}
test "vt_write" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
const str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Hello", str);
}
test "vt_write split escape sequence" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Write "Hello" in bold by splitting the CSI bold sequence across two writes.
// ESC [ 1 m = bold on, ESC [ 0 m = reset
// Split ESC from the rest of the CSI sequence.
vt_write(t, "Hello \x1b", 7);
vt_write(t, "[1mBold\x1b[0m", 10);
const str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
// If the escape sequence leaked, we'd see "[1mBold" as literal text.
try testing.expectEqualStrings("Hello Bold", str);
}
test "vt_write split combining mark after base at right edge" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
2,
2,
));
defer free(t);
// Put "å" in the final column, then send its combining low line in a
// separate write so the mark arrives while the cursor has a pending wrap.
vt_write(t, "", 3);
vt_write(t, "\xcc\xb2", 2);
const str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("xå̲", str);
}
test "get cols and rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var cols: size.CellCountInt = undefined;
var rows: size.CellCountInt = undefined;
try testing.expectEqual(Result.success, get(t, .cols, @ptrCast(&cols)));
try testing.expectEqual(Result.success, get(t, .rows, @ptrCast(&rows)));
try testing.expectEqual(80, cols);
try testing.expectEqual(24, rows);
}
test "get cursor position" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
var x: size.CellCountInt = undefined;
var y: size.CellCountInt = undefined;
try testing.expectEqual(Result.success, get(t, .cursor_x, @ptrCast(&x)));
try testing.expectEqual(Result.success, get(t, .cursor_y, @ptrCast(&y)));
try testing.expectEqual(5, x);
try testing.expectEqual(0, y);
}
test "get vt_processing_error" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var processing_error: bool = true;
try testing.expectEqual(Result.success, get(
t,
.vt_processing_error,
@ptrCast(&processing_error),
));
try testing.expect(!processing_error);
// Force a non-graceful terminal-owned update failure through the public
// VT write path.
{
const alloc = t.?.terminal.screens.active.alloc;
t.?.terminal.screens.active.alloc = testing.failing_allocator;
defer t.?.terminal.screens.active.alloc = alloc;
const input = "\x1B]2;unavailable\x1B\\";
vt_write(t, input, input.len);
}
try testing.expectEqual(Result.success, get(
t,
.vt_processing_error,
@ptrCast(&processing_error),
));
try testing.expect(processing_error);
reset(t);
try testing.expectEqual(Result.success, get(
t,
.vt_processing_error,
@ptrCast(&processing_error),
));
try testing.expect(processing_error);
}
test "get null" {
var cols: size.CellCountInt = undefined;
try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols)));
}
test "get cursor_visible" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var visible: bool = undefined;
try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible)));
try testing.expect(visible);
// DEC mode 25 controls cursor visibility
var config: ModeConfig = .{
.mode = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }),
.value = false,
};
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible)));
try testing.expect(!visible);
}
test "get active_screen" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var screen: TerminalScreen = undefined;
try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen)));
try testing.expectEqual(.primary, screen);
}
test "get kitty_keyboard_flags" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var flags: u8 = undefined;
try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags)));
try testing.expectEqual(0, flags);
// Push kitty flags via VT sequence: CSI > 3 u (push disambiguate | report_events)
vt_write(t, "\x1b[>3u", 5);
try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags)));
try testing.expectEqual(3, flags);
}
test "get mouse_tracking" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var tracking: bool = undefined;
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(!tracking);
// Enable X10 mouse (DEC mode 9)
var config: ModeConfig = .{
.mode = @bitCast(modes.ModeTag{ .value = 9, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(tracking);
// Disable X10, enable normal mouse (DEC mode 1000)
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
config = .{
.mode = @bitCast(modes.ModeTag{ .value = 1000, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(tracking);
// Disable normal, enable button mouse (DEC mode 1002)
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
config = .{
.mode = @bitCast(modes.ModeTag{ .value = 1002, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(tracking);
// Disable button, enable any mouse (DEC mode 1003)
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
config = .{
.mode = @bitCast(modes.ModeTag{ .value = 1003, .ansi = false }),
.value = true,
};
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(tracking);
// Disable all - should be false again
config.value = false;
try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config)));
try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking)));
try testing.expect(!tracking);
}
test "get total_rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var total: usize = undefined;
try testing.expectEqual(Result.success, get(t, .total_rows, @ptrCast(&total)));
try testing.expect(total >= 24);
}
test "get scrollback_rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
3,
));
defer free(t);
var scrollback: usize = undefined;
try testing.expectEqual(Result.success, get(t, .scrollback_rows, @ptrCast(&scrollback)));
try testing.expectEqual(@as(usize, 0), scrollback);
// Write enough lines to push content into scrollback
vt_write(t, "line1\r\nline2\r\nline3\r\nline4\r\nline5\r\n", 34);
try testing.expectEqual(Result.success, get(t, .scrollback_rows, @ptrCast(&scrollback)));
try testing.expectEqual(@as(usize, 2), scrollback);
}
test "get configured scrollback limits" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
3,
));
defer free(t);
var value: usize = undefined;
try testing.expectEqual(
Result.success,
get(t, .scrollback_max_bytes, @ptrCast(&value)),
);
try testing.expectEqual(@as(usize, 10_000), value);
try testing.expectEqual(
Result.no_value,
get(t, .scrollback_max_lines, @ptrCast(&value)),
);
const max_bytes: usize = 0;
const max_lines: usize = 12;
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_bytes, &max_bytes),
);
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_lines, &max_lines),
);
try testing.expectEqual(
Result.success,
get(t, .scrollback_max_bytes, @ptrCast(&value)),
);
try testing.expectEqual(max_bytes, value);
try testing.expectEqual(
Result.success,
get(t, .scrollback_max_lines, @ptrCast(&value)),
);
try testing.expectEqual(max_lines, value);
// The configured limits belong to the primary screen and remain
// readable while an alternate screen is active.
vt_write(t, "\x1b[?1049h", 8);
try testing.expectEqual(
Result.success,
get(t, .scrollback_max_bytes, @ptrCast(&value)),
);
try testing.expectEqual(max_bytes, value);
try testing.expectEqual(
Result.success,
get(t, .scrollback_max_lines, @ptrCast(&value)),
);
try testing.expectEqual(max_lines, value);
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_bytes, null),
);
try testing.expectEqual(
Result.success,
set(t, .scrollback_max_lines, null),
);
try testing.expectEqual(
Result.no_value,
get(t, .scrollback_max_bytes, @ptrCast(&value)),
);
try testing.expectEqual(
Result.no_value,
get(t, .scrollback_max_lines, @ptrCast(&value)),
);
}
test "get invalid" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
test "set default cursor style and blink" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var default_style: TerminalCursorStyle = .bar;
var default_blink = true;
try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink)));
// Setting defaults applies them immediately while the cursor is still default.
try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
// An explicit DECSCUSR style overrides the configured defaults.
vt_write(t, "\x1b[2 q", 5);
try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
// Changing defaults does not override an explicit cursor style.
default_style = .underline;
try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
// DECSCUSR reset restores the configured default style and blink.
vt_write(t, "\x1b[0 q", 5);
try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
// RIS also restores cursor defaults from Terminal-owned state.
vt_write(t, "\x1b[2 q", 5);
reset(t);
try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style);
try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
}
test "set and get selection" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &end_ref));
var out: selection_c.CSelection = undefined;
try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
try testing.expectEqual(Result.success, set(t, .selection, @ptrCast(&sel)));
try testing.expect(t.?.terminal.screens.active.selection.?.tracked());
try testing.expectEqual(Result.success, get(t, .selection, @ptrCast(&out)));
try testing.expect(out.start.toPin().?.eql(start_ref.toPin().?));
try testing.expect(out.end.toPin().?.eql(end_ref.toPin().?));
try testing.expect(out.rectangle);
try testing.expectEqual(Result.success, set(t, .selection, null));
try testing.expect(t.?.terminal.screens.active.selection == null);
try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
}
test "selection derivation helpers" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, " Hello \r\nWorld", 16);
var out: selection_c.CSelection = undefined;
var word_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &word_ref));
var empty_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 20, .y = 0 } },
}, &empty_ref));
var line_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &line_ref));
var word_opts: selection_c.SelectWordOptions = .{
.ref = word_ref,
};
try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
word_opts.ref = empty_ref;
try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out));
var between_start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 20, .y = 1 } },
}, &between_start_ref));
var between_end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 1 } },
}, &between_end_ref));
var word_between_opts: selection_c.SelectWordBetweenOptions = .{
.start = between_start_ref,
.end = between_end_ref,
};
try testing.expectEqual(Result.success, selection_c.word_between(t, &word_between_opts, &out));
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
var line_opts: selection_c.SelectLineOptions = .{
.ref = line_ref,
};
try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
try testing.expectEqual(Result.success, selection_c.all(t, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out));
line_opts.size = @sizeOf(usize) - 1;
try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null));
try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, null, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, &word_between_opts, null));
}
test "selection_adjust mutates snapshot end" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &end_ref));
var sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x);
try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .left));
try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
sel = .{
.start = end_ref,
.end = start_ref,
};
try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
}
test "selection_order and selection_ordered" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello\r\nWorld", 12);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 1 } },
}, &end_ref));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
var order: selection_c.Order = undefined;
try testing.expectEqual(Result.success, selection_c.order(t, &sel, &order));
try testing.expectEqual(selection_c.Order.mirrored_forward, order);
var out: selection_c.CSelection = undefined;
try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .forward, &out));
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
try testing.expect(out.rectangle);
try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .reverse, &out));
try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y);
try testing.expect(out.rectangle);
}
test "selection_contains" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello\r\nWorld", 12);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 1 } },
}, &end_ref));
const linear: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
var contains: bool = undefined;
try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
.tag = .active,
.value = .{ .active = .{ .x = 4, .y = 0 } },
}, &contains));
try testing.expect(contains);
try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &contains));
try testing.expect(!contains);
const rectangle: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
try testing.expectEqual(Result.success, selection_c.contains(t, &rectangle, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &contains));
try testing.expect(contains);
}
test "selection_equal" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var other_t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&other_t,
80,
24,
));
defer free(other_t);
vt_write(t, "Hello", 5);
vt_write(other_t, "Hello", 5);
var start_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &start_ref));
var end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &end_ref));
var other_end_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &other_end_ref));
var cross_terminal_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(other_t, .{
.tag = .active,
.value = .{ .active = .{ .x = 1, .y = 0 } },
}, &cross_terminal_ref));
const sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
const equal_sel: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
};
const different_endpoint: selection_c.CSelection = .{
.start = start_ref,
.end = other_end_ref,
};
const different_rectangle: selection_c.CSelection = .{
.start = start_ref,
.end = end_ref,
.rectangle = true,
};
const cross_terminal: selection_c.CSelection = .{
.start = start_ref,
.end = cross_terminal_ref,
};
var equal: bool = undefined;
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal));
try testing.expect(equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_endpoint, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &cross_terminal, &equal));
try testing.expect(!equal);
try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null));
}
test "selection_order invalid values" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var order: selection_c.Order = undefined;
try testing.expectEqual(Result.invalid_value, selection_c.order(null, null, &order));
try testing.expectEqual(Result.invalid_value, selection_c.order(t, null, &order));
}
test "grid_ref" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
var out_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &out_ref));
// Extract cell from grid ref and verify it contains 'H'
var out_cell: cell_c.CCell = undefined;
try testing.expectEqual(Result.success, grid_ref_c.grid_ref_cell(&out_ref, &out_cell));
var cp: u32 = 0;
try testing.expectEqual(Result.success, cell_c.get(out_cell, .codepoint, @ptrCast(&cp)));
try testing.expectEqual(@as(u32, 'H'), cp);
}
test "grid_ref null terminal" {
var out_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.invalid_value, grid_ref(null, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &out_ref));
}
test "point_from_grid_ref roundtrip active" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
// Get a grid ref at (2, 0) in active coords
var ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 2, .y = 0 } },
}, &ref));
// Convert back to active coords
var coord: point.Coordinate = undefined;
try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .active, &coord));
try testing.expectEqual(@as(size.CellCountInt, 2), coord.x);
try testing.expectEqual(@as(u32, 0), coord.y);
}
test "point_from_grid_ref roundtrip viewport" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
vt_write(t, "Hello", 5);
var ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .viewport,
.value = .{ .viewport = .{ .x = 0, .y = 0 } },
}, &ref));
var coord: point.Coordinate = undefined;
try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .viewport, &coord));
try testing.expectEqual(@as(size.CellCountInt, 0), coord.x);
try testing.expectEqual(@as(u32, 0), coord.y);
}
test "point_from_grid_ref history ref to active returns no_value" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
4,
));
defer free(t);
// Write enough lines to push content into scrollback
for (0..10) |_| {
vt_write(t, "line\n", 5);
}
// Get a ref to the first line (now in scrollback)
var ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .screen,
.value = .{ .screen = .{ .x = 0, .y = 0 } },
}, &ref));
// Should succeed for screen coords
var coord: point.Coordinate = undefined;
try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .screen, &coord));
try testing.expectEqual(@as(u32, 0), coord.y);
// Should fail for active coords (it's in scrollback)
try testing.expectEqual(Result.no_value, point_from_grid_ref(t, &ref, .active, &coord));
}
test "point_from_grid_ref null terminal" {
var ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.invalid_value, point_from_grid_ref(null, &ref, .active, null));
}
test "point_from_grid_ref null node" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.invalid_value, point_from_grid_ref(t, &ref, .active, null));
}
test "set write_pty callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
var last_userdata: ?*anyopaque = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
last_userdata = null;
}
fn writePty(_: Terminal, ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
last_userdata = ud;
}
};
defer S.deinit();
// Set userdata and write_pty callback
var sentinel: u8 = 42;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// DECRQM for wraparound mode (mode 7, set by default) should trigger write_pty
vt_write(t, "\x1B[?7$p", 6);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1B[?7;1$y", S.last_data.?);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
}
test "write_pty receives DECRQSS response" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |data| testing.allocator.free(data);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |data| testing.allocator.free(data);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
const query = "\x1B[1m\x1BP$qm\x1B\\";
vt_write(t, query, query.len);
try testing.expectEqualStrings("\x1BP1$r0;1m\x1B\\", S.last_data.?);
}
test "write_pty receives OSC color query response" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
const set_fg = "\x1B]10;rgb:01/02/03\x1B\\";
vt_write(t, set_fg, set_fg.len);
try testing.expect(S.last_data == null);
const query_fg = "\x1B]10;?\x1B\\";
vt_write(t, query_fg, query_fg.len);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?);
}
test "set write_pty without callback ignores queries" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Without setting a callback, DECRQM should be silently ignored (no crash)
vt_write(t, "\x1B[?7$p", 6);
}
test "set write_pty null clears callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var called: bool = false;
fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
called = true;
}
};
S.called = false;
// Set then clear the callback
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .write_pty, null));
vt_write(t, "\x1B[?7$p", 6);
try testing.expect(!S.called);
}
test "set bell callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var bell_count: usize = 0;
var last_userdata: ?*anyopaque = null;
fn bell(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
bell_count += 1;
last_userdata = ud;
}
};
S.bell_count = 0;
S.last_userdata = null;
// Set userdata and bell callback
var sentinel: u8 = 99;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell)));
// Single BEL
vt_write(t, "\x07", 1);
try testing.expectEqual(@as(usize, 1), S.bell_count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
// Multiple BELs
vt_write(t, "\x07\x07", 2);
try testing.expectEqual(@as(usize, 3), S.bell_count);
}
test "bell without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// BEL without a callback should not crash
vt_write(t, "\x07", 1);
}
test "set enquiry callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
const response = "OK";
fn enquiry(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) lib.String {
return .{ .ptr = response, .len = response.len };
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .enquiry, @ptrCast(&S.enquiry)));
// ENQ (0x05) should trigger the enquiry callback and write response via write_pty
vt_write(t, "\x05", 1);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("OK", S.last_data.?);
}
test "enquiry without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// ENQ without a callback should not crash
vt_write(t, "\x05", 1);
}
test "set xtversion callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
const version = "myterm 1.0";
fn xtversion(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) lib.String {
return .{ .ptr = version, .len = version.len };
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .xtversion, @ptrCast(&S.xtversion)));
// XTVERSION: CSI > q
vt_write(t, "\x1B[>q", 4);
try testing.expect(S.last_data != null);
// Response should be DCS >| version ST
try testing.expectEqualStrings("\x1BP>|myterm 1.0\x1B\\", S.last_data.?);
}
test "xtversion without callback reports default" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
// Set write_pty but not xtversion — should get default "libghostty"
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
vt_write(t, "\x1B[>q", 4);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?);
}
test "set title_changed callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var title_count: usize = 0;
var last_userdata: ?*anyopaque = null;
fn titleChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
title_count += 1;
last_userdata = ud;
}
};
S.title_count = 0;
S.last_userdata = null;
var sentinel: u8 = 77;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .title_changed, @ptrCast(&S.titleChanged)));
// OSC 2 ; title ST — set window title
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
try testing.expectEqual(@as(usize, 1), S.title_count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
// Another title change
vt_write(t, "\x1B]2;World\x1B\\", 10);
try testing.expectEqual(@as(usize, 2), S.title_count);
}
test "title_changed without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// OSC 2 without a callback should not crash
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
test "set desktop_notification callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var count: usize = 0;
var last_userdata: ?*anyopaque = null;
var last_size: usize = 0;
var title: [64]u8 = undefined;
var title_len: usize = 0;
var body: [64]u8 = undefined;
var body_len: usize = 0;
fn desktopNotification(
_: Terminal,
ud: ?*anyopaque,
notification: *const DesktopNotification,
) callconv(lib.calling_conv) void {
count += 1;
last_userdata = ud;
last_size = notification.size;
title_len = notification.title.len;
body_len = notification.body.len;
@memcpy(title[0..title_len], notification.title.ptr[0..title_len]);
@memcpy(body[0..body_len], notification.body.ptr[0..body_len]);
}
};
S.count = 0;
S.last_userdata = null;
S.last_size = 0;
S.title_len = 0;
S.body_len = 0;
var sentinel: u8 = 99;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(
t,
.desktop_notification,
@ptrCast(&S.desktopNotification),
));
// Split OSC 777 across writes to exercise the persistent VT parser.
const seq_a = "\x1B]777;notify;Codex;";
const seq_b = "Needs attention\x1B\\";
vt_write(t, seq_a, seq_a.len);
try testing.expectEqual(@as(usize, 0), S.count);
vt_write(t, seq_b, seq_b.len);
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(@sizeOf(DesktopNotification), S.last_size);
try testing.expectEqualStrings("Codex", S.title[0..S.title_len]);
try testing.expectEqualStrings("Needs attention", S.body[0..S.body_len]);
// OSC 9 has no title and preserves its body.
const seq_c = "\x1B]9;Build complete\x07";
vt_write(t, seq_c, seq_c.len);
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expectEqualStrings("", S.title[0..S.title_len]);
try testing.expectEqualStrings("Build complete", S.body[0..S.body_len]);
// Removing the callback takes effect immediately.
try testing.expectEqual(Result.success, set(t, .desktop_notification, null));
vt_write(t, seq_c, seq_c.len);
try testing.expectEqual(@as(usize, 2), S.count);
}
test "set progress_report callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var count: usize = 0;
var last_userdata: ?*anyopaque = null;
var last_size: usize = 0;
var last_state: ProgressState = .remove;
var last_progress: i8 = -1;
fn progressReport(
_: Terminal,
ud: ?*anyopaque,
report: *const ProgressReport,
) callconv(lib.calling_conv) void {
count += 1;
last_userdata = ud;
last_size = report.size;
last_state = report.state;
last_progress = report.progress;
}
};
S.count = 0;
S.last_userdata = null;
S.last_size = 0;
S.last_state = .remove;
S.last_progress = -1;
var sentinel: u8 = 100;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(
t,
.progress_report,
@ptrCast(&S.progressReport),
));
const cases = [_]struct {
sequence: []const u8,
state: ProgressState,
progress: i8,
}{
.{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = -1 },
.{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 },
.{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 },
.{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = -1 },
.{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 },
};
for (cases, 1..) |case, expected_count| {
const midpoint = case.sequence.len / 2;
vt_write(t, case.sequence.ptr, midpoint);
try testing.expectEqual(expected_count - 1, S.count);
vt_write(t, case.sequence.ptr + midpoint, case.sequence.len - midpoint);
try testing.expectEqual(expected_count, S.count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(@sizeOf(ProgressReport), S.last_size);
try testing.expectEqual(case.state, S.last_state);
try testing.expectEqual(case.progress, S.last_progress);
}
try testing.expectEqual(Result.success, set(t, .progress_report, null));
const ignored = "\x1B]9;4;1;90\x1B\\";
vt_write(t, ignored, ignored.len);
try testing.expectEqual(@as(usize, cases.len), S.count);
}
test "set pwd_changed callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var pwd_count: usize = 0;
var last_userdata: ?*anyopaque = null;
fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
pwd_count += 1;
last_userdata = ud;
}
};
S.pwd_count = 0;
S.last_userdata = null;
var sentinel: u8 = 88;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged)));
// OSC 7 ; file:///tmp ST — report pwd
const seq1 = "\x1B]7;file:///tmp\x1B\\";
vt_write(t, seq1, seq1.len);
try testing.expectEqual(@as(usize, 1), S.pwd_count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
// Another pwd change
const seq2 = "\x1B]7;file:///home/user\x1B\\";
vt_write(t, seq2, seq2.len);
try testing.expectEqual(@as(usize, 2), S.pwd_count);
try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
}
test "set clipboard_write callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var count: usize = 0;
var last_terminal: Terminal = null;
var last_userdata: ?*anyopaque = null;
var last_size: usize = 0;
var last_location: clipboard.Location = .standard;
var last_contents_null: bool = false;
var last_contents_len: usize = 0;
var last_mimes: [8][64]u8 = undefined;
var last_mime_lens: [8]usize = @splat(0);
var last_data: [8][64]u8 = undefined;
var last_data_lens: [8]usize = @splat(0);
var next_result: clipboard.WriteResult = .success;
fn clipboardWrite(
terminal_: Terminal,
ud: ?*anyopaque,
request: *const ClipboardWrite,
) callconv(lib.calling_conv) clipboard.WriteResult {
count += 1;
last_terminal = terminal_;
last_userdata = ud;
last_size = request.size;
last_location = request.location;
last_contents_null = request.contents == null;
last_contents_len = request.contents_len;
if (request.contents) |ptr| {
for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
@memcpy(
last_mimes[i][0..last_mime_lens[i]],
content.mime.ptr[0..last_mime_lens[i]],
);
last_data_lens[i] = @min(content.data.len, last_data[i].len);
@memcpy(
last_data[i][0..last_data_lens[i]],
content.data.ptr[0..last_data_lens[i]],
);
}
}
return next_result;
}
};
S.count = 0;
S.last_terminal = null;
S.last_userdata = null;
S.next_result = .denied;
var sentinel: u8 = 88;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
// Split OSC 52 write whose decoded payload contains an embedded NUL.
const seq1_a = "\x1B]52;c;aGVs";
const seq1_b = "bG8Ad29ybGQ=\x1B\\";
vt_write(t, seq1_a, seq1_a.len);
vt_write(t, seq1_b, seq1_b.len);
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqual(t, S.last_terminal);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expect(!S.last_contents_null);
try testing.expectEqual(@as(usize, 1), S.last_contents_len);
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]);
// OSC 52 destinations are normalized rather than exposed as wire bytes.
const location_cases = [_]struct {
selector: u8,
expected: clipboard.Location,
}{
.{ .selector = 's', .expected = .selection },
.{ .selector = 'p', .expected = .primary },
.{ .selector = 'q', .expected = .standard },
};
for (location_cases) |case| {
const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' };
vt_write(t, &seq, seq.len);
try testing.expectEqual(case.expected, S.last_location);
}
try testing.expectEqual(@as(usize, 4), S.count);
// An empty content list is a clear and retains a null descriptor pointer.
const clear = "\x1B]52;s;\x1B\\";
vt_write(t, clear, clear.len);
try testing.expectEqual(@as(usize, 5), S.count);
try testing.expectEqual(clipboard.Location.selection, S.last_location);
try testing.expect(S.last_contents_null);
try testing.expectEqual(@as(usize, 0), S.last_contents_len);
// Read requests and malformed base64 must never reach the callback.
const read = "\x1B]52;c;?\x1B\\";
vt_write(t, read, read.len);
const malformed = "\x1B]52;c;%%%\x1B\\";
vt_write(t, malformed, malformed.len);
try testing.expectEqual(@as(usize, 5), S.count);
// iTerm2 Copy reaches the same normalized callback.
const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\";
vt_write(t, iterm, iterm.len);
try testing.expectEqual(@as(usize, 6), S.count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]);
// Every representation is converted, and callback results propagate
// through the C trampoline for protocols that can acknowledge writes.
const internal_contents = [_]clipboard.Content{
.{ .mime = "text/plain", .data = "plain" },
.{ .mime = "application/octet-stream", .data = "a\x00b" },
.{ .mime = "text/html", .data = "<b>plain</b>" },
.{ .mime = "text/rtf", .data = "{\\rtf1 plain}" },
.{ .mime = "image/png", .data = "\x89PNG" },
};
S.next_result = .busy;
const handler = &t.?.stream.handler;
const write_result = handler.effects.clipboard_write.?(handler, .{
.location = .primary,
.contents = &internal_contents,
});
try testing.expectEqual(clipboard.WriteResult.busy, write_result);
try testing.expectEqual(@as(usize, 7), S.count);
try testing.expectEqual(@as(usize, 5), S.last_contents_len);
try testing.expectEqualStrings(
"application/octet-stream",
S.last_mimes[1][0..S.last_mime_lens[1]],
);
try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]);
try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
// Removing the callback takes effect immediately.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
const after_remove = "\x1B]52;c;eA==\x1B\\";
vt_write(t, after_remove, after_remove.len);
try testing.expectEqual(@as(usize, 7), S.count);
}
test "clipboard_write without callback is unsupported and silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// OSC 52 without a callback should not crash
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
vt_write(t, seq, seq.len);
const handler = &t.?.stream.handler;
const result = handler.effects.clipboard_write.?(handler, .{
.location = .standard,
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
});
try testing.expectEqual(clipboard.WriteResult.unsupported, result);
}
test "pwd_changed without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// OSC 7 without a callback should not crash, but should still set the pwd
const seq = "\x1B]7;file:///tmp\x1B\\";
vt_write(t, seq, seq.len);
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
}
test "set size callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool {
out_size.* = .{
.rows = 24,
.columns = 80,
.cell_width = 8,
.cell_height = 16,
};
return true;
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .size_cb, @ptrCast(&S.sizeCb)));
// CSI 18 t — report text area size in characters
vt_write(t, "\x1B[18t", 5);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[8;24;80t", S.last_data.?);
}
test "size without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// CSI 18 t without a size callback should not crash
vt_write(t, "\x1B[18t", 5);
}
test "set device_attributes callback primary" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool {
out.* = .{
.primary = .{
.conformance_level = 64,
.features = .{ 22, 52 } ++ .{0} ** 62,
.num_features = 2,
},
.secondary = .{
.device_type = 1,
.firmware_version = 10,
.rom_cartridge = 0,
},
.tertiary = .{ .unit_id = 0 },
};
return true;
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da)));
// CSI c — primary DA
vt_write(t, "\x1B[c", 3);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[?64;22;52c", S.last_data.?);
}
test "set device_attributes callback secondary" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool {
out.* = .{
.primary = .{
.conformance_level = 62,
.features = .{22} ++ .{0} ** 63,
.num_features = 1,
},
.secondary = .{
.device_type = 1,
.firmware_version = 10,
.rom_cartridge = 0,
},
.tertiary = .{ .unit_id = 0 },
};
return true;
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da)));
// CSI > c — secondary DA
vt_write(t, "\x1B[>c", 4);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[>1;10;0c", S.last_data.?);
}
test "set device_attributes callback tertiary" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool {
out.* = .{
.primary = .{
.conformance_level = 62,
.features = .{0} ** 64,
.num_features = 0,
},
.secondary = .{
.device_type = 1,
.firmware_version = 0,
.rom_cartridge = 0,
},
.tertiary = .{ .unit_id = 0xAABBCCDD },
};
return true;
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da)));
// CSI = c — tertiary DA
vt_write(t, "\x1B[=c", 4);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", S.last_data.?);
}
test "device_attributes without callback uses default" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// Without setting a device_attributes callback, DA1 should return the default
vt_write(t, "\x1B[c", 3);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?);
}
test "device_attributes callback returns false uses default" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn da(_: Terminal, _: ?*anyopaque, _: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool {
return false;
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da)));
// Callback returns false, should use default response
vt_write(t, "\x1B[c", 3);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?);
}
test "set and get title" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// No title set yet — should return empty string
var title: lib.String = undefined;
try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title)));
try testing.expectEqual(@as(usize, 0), title.len);
// Set title via option
const hello: lib.String = .{ .ptr = "Hello", .len = 5 };
try testing.expectEqual(Result.success, set(t, .title, @ptrCast(&hello)));
try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title)));
try testing.expectEqualStrings("Hello", title.ptr[0..title.len]);
// Overwrite title
const world: lib.String = .{ .ptr = "World", .len = 5 };
try testing.expectEqual(Result.success, set(t, .title, @ptrCast(&world)));
try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title)));
try testing.expectEqualStrings("World", title.ptr[0..title.len]);
// Clear title with NULL
try testing.expectEqual(Result.success, set(t, .title, null));
try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title)));
try testing.expectEqual(@as(usize, 0), title.len);
}
test "set and get pwd" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// No pwd set yet — should return empty string
var pwd: lib.String = undefined;
try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd)));
try testing.expectEqual(@as(usize, 0), pwd.len);
// Set pwd via option
const home: lib.String = .{ .ptr = "/home/user", .len = 10 };
try testing.expectEqual(Result.success, set(t, .pwd, @ptrCast(&home)));
try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd)));
try testing.expectEqualStrings("/home/user", pwd.ptr[0..pwd.len]);
// Clear pwd with NULL
try testing.expectEqual(Result.success, set(t, .pwd, null));
try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd)));
try testing.expectEqual(@as(usize, 0), pwd.len);
}
test "get title set via vt_write" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Set title via OSC 2
vt_write(t, "\x1B]2;VT Title\x1B\\", 14);
var title: lib.String = undefined;
try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title)));
try testing.expectEqualStrings("VT Title", title.ptr[0..title.len]);
}
test "title report requires explicit opt in" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |data| testing.allocator.free(data);
last_data = null;
}
fn writePty(
_: Terminal,
_: ?*anyopaque,
ptr: [*]const u8,
len: usize,
) callconv(lib.calling_conv) void {
if (last_data) |data| testing.allocator.free(data);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
S.last_data = null;
defer S.deinit();
try testing.expectEqual(
Result.success,
set(t, .write_pty, @ptrCast(&S.writePty)),
);
const set_title = "\x1B]2;echo vulnerable\x1B\\";
const query_title = "\x1B[21t";
vt_write(t, set_title, set_title.len);
// WRITE_PTY alone must not enable the security-sensitive response.
vt_write(t, query_title, query_title.len);
try testing.expect(S.last_data == null);
const enabled = true;
try testing.expectEqual(Result.success, set(t, .title_report, &enabled));
vt_write(t, query_title, query_title.len);
try testing.expectEqualStrings(
"\x1b]lecho vulnerable\x1b\\",
S.last_data.?,
);
// NULL restores the secure default.
S.deinit();
try testing.expectEqual(Result.success, set(t, .title_report, null));
vt_write(t, query_title, query_title.len);
try testing.expect(S.last_data == null);
}
test "resize updates pixel dimensions" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Pixel geometry must still be applied when the cell dimensions match.
try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18));
const zt = t.?.terminal;
try testing.expectEqual(@as(u32, 80 * 9), zt.width_px);
try testing.expectEqual(@as(u32, 24 * 18), zt.height_px);
}
test "resize pixel overflow saturates" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.success, resize(t, 100, 40, std.math.maxInt(u32), std.math.maxInt(u32)));
const zt = t.?.terminal;
try testing.expectEqual(std.math.maxInt(u32), zt.width_px);
try testing.expectEqual(std.math.maxInt(u32), zt.height_px);
}
test "resize disables synchronized output" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const zt = t.?.terminal;
zt.modes.set(.synchronized_output, true);
// The terminal-level reset must run even if grid work is unnecessary.
try testing.expectEqual(Result.success, resize(t, 80, 24, 9, 18));
try testing.expect(!zt.modes.get(.synchronized_output));
}
test "resize sends in-band size report" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// Enable in-band size reports (mode 2048)
t.?.terminal.modes.set(.in_band_size_reports, true);
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
// Expected: \x1B[48;rows;cols;height_px;width_pxt
// height_px = 40*18 = 720, width_px = 100*9 = 900
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1B[48;40;100;720;900t", S.last_data.?);
}
test "resize no size report without mode 2048" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var called: bool = false;
fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
called = true;
}
};
S.called = false;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// in_band_size_reports is off by default
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
try testing.expect(!S.called);
}
test "resize in-band report without write_pty callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Enable mode 2048 but don't set a write_pty callback — should not crash
t.?.terminal.modes.set(.in_band_size_reports, true);
try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18));
}
test "resize null terminal" {
try testing.expectEqual(Result.invalid_value, resize(null, 100, 40, 9, 18));
}
test "resize zero cols" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 0, 40, 9, 18));
}
test "resize zero rows" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
try testing.expectEqual(Result.invalid_value, resize(t, 100, 0, 9, 18));
}
test "grid_ref out of bounds" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var out_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.invalid_value, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 100, .y = 0 } },
}, &out_ref));
}
test "set and get color_foreground" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Initially unset
var rgb: color.RGB.C = undefined;
try testing.expectEqual(Result.no_value, get(t, .color_foreground, @ptrCast(&rgb)));
// Set a value
const fg: color.RGB.C = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC };
try testing.expectEqual(Result.success, set(t, .color_foreground, @ptrCast(&fg)));
try testing.expectEqual(Result.success, get(t, .color_foreground, @ptrCast(&rgb)));
try testing.expectEqual(fg, rgb);
// Clear with null
try testing.expectEqual(Result.success, set(t, .color_foreground, null));
try testing.expectEqual(Result.no_value, get(t, .color_foreground, @ptrCast(&rgb)));
}
test "set and get color_background" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var rgb: color.RGB.C = undefined;
try testing.expectEqual(Result.no_value, get(t, .color_background, @ptrCast(&rgb)));
const bg: color.RGB.C = .{ .r = 0x11, .g = 0x22, .b = 0x33 };
try testing.expectEqual(Result.success, set(t, .color_background, @ptrCast(&bg)));
try testing.expectEqual(Result.success, get(t, .color_background, @ptrCast(&rgb)));
try testing.expectEqual(bg, rgb);
try testing.expectEqual(Result.success, set(t, .color_background, null));
try testing.expectEqual(Result.no_value, get(t, .color_background, @ptrCast(&rgb)));
}
test "set and get color_cursor" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var rgb: color.RGB.C = undefined;
try testing.expectEqual(Result.no_value, get(t, .color_cursor, @ptrCast(&rgb)));
const cur: color.RGB.C = .{ .r = 0xFF, .g = 0x00, .b = 0x88 };
try testing.expectEqual(Result.success, set(t, .color_cursor, @ptrCast(&cur)));
try testing.expectEqual(Result.success, get(t, .color_cursor, @ptrCast(&rgb)));
try testing.expectEqual(cur, rgb);
try testing.expectEqual(Result.success, set(t, .color_cursor, null));
try testing.expectEqual(Result.no_value, get(t, .color_cursor, @ptrCast(&rgb)));
}
test "set and get color_palette" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Get default palette
var palette: color.PaletteC = undefined;
try testing.expectEqual(Result.success, get(t, .color_palette, @ptrCast(&palette)));
try testing.expectEqual(color.default[0].cval(), palette[0]);
// Set custom palette
var custom: color.PaletteC = color.paletteCval(&color.default);
custom[0] = .{ .r = 0x12, .g = 0x34, .b = 0x56 };
try testing.expectEqual(Result.success, set(t, .color_palette, @ptrCast(&custom)));
try testing.expectEqual(Result.success, get(t, .color_palette, @ptrCast(&palette)));
try testing.expectEqual(custom[0], palette[0]);
// Reset with null restores default
try testing.expectEqual(Result.success, set(t, .color_palette, null));
try testing.expectEqual(Result.success, get(t, .color_palette, @ptrCast(&palette)));
try testing.expectEqual(color.default[0].cval(), palette[0]);
}
test "get color default vs effective with override" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const zt = t.?.terminal;
var rgb: color.RGB.C = undefined;
// Set defaults
const fg: color.RGB.C = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC };
const bg: color.RGB.C = .{ .r = 0x11, .g = 0x22, .b = 0x33 };
const cur: color.RGB.C = .{ .r = 0xFF, .g = 0x00, .b = 0x88 };
try testing.expectEqual(Result.success, set(t, .color_foreground, @ptrCast(&fg)));
try testing.expectEqual(Result.success, set(t, .color_background, @ptrCast(&bg)));
try testing.expectEqual(Result.success, set(t, .color_cursor, @ptrCast(&cur)));
// Simulate OSC overrides
const override: color.RGB = .{ .r = 0x00, .g = 0x00, .b = 0x00 };
zt.colors.foreground.override = override;
zt.colors.background.override = override;
zt.colors.cursor.override = override;
// Effective returns override
try testing.expectEqual(Result.success, get(t, .color_foreground, @ptrCast(&rgb)));
try testing.expectEqual(override.cval(), rgb);
try testing.expectEqual(Result.success, get(t, .color_background, @ptrCast(&rgb)));
try testing.expectEqual(override.cval(), rgb);
try testing.expectEqual(Result.success, get(t, .color_cursor, @ptrCast(&rgb)));
try testing.expectEqual(override.cval(), rgb);
// Default returns original
try testing.expectEqual(Result.success, get(t, .color_foreground_default, @ptrCast(&rgb)));
try testing.expectEqual(fg, rgb);
try testing.expectEqual(Result.success, get(t, .color_background_default, @ptrCast(&rgb)));
try testing.expectEqual(bg, rgb);
try testing.expectEqual(Result.success, get(t, .color_cursor_default, @ptrCast(&rgb)));
try testing.expectEqual(cur, rgb);
}
test "get color default returns no_value when unset" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var rgb: color.RGB.C = undefined;
try testing.expectEqual(Result.no_value, get(t, .color_foreground_default, @ptrCast(&rgb)));
try testing.expectEqual(Result.no_value, get(t, .color_background_default, @ptrCast(&rgb)));
try testing.expectEqual(Result.no_value, get(t, .color_cursor_default, @ptrCast(&rgb)));
}
test "get color_palette_default vs current" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const zt = t.?.terminal;
// Set a custom default palette
var custom: color.PaletteC = color.paletteCval(&color.default);
custom[0] = .{ .r = 0x12, .g = 0x34, .b = 0x56 };
try testing.expectEqual(Result.success, set(t, .color_palette, @ptrCast(&custom)));
// Simulate OSC override on index 0
zt.colors.palette.set(0, .{ .r = 0xFF, .g = 0xFF, .b = 0xFF });
// Current palette returns the override
var palette: color.PaletteC = undefined;
try testing.expectEqual(Result.success, get(t, .color_palette, @ptrCast(&palette)));
try testing.expectEqual(color.RGB.C{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, palette[0]);
// Default palette returns the original
try testing.expectEqual(Result.success, get(t, .color_palette_default, @ptrCast(&palette)));
try testing.expectEqual(custom[0], palette[0]);
}
test "set color sets dirty flag" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const zt = t.?.terminal;
zt.flags.dirty.palette = false;
const fg: color.RGB.C = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF };
try testing.expectEqual(Result.success, set(t, .color_foreground, @ptrCast(&fg)));
try testing.expect(zt.flags.dirty.palette);
}
test "set glyph protocol disables APC handling and clears glossary" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\";
vt_write(t, register, register.len);
try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
const disabled = false;
try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled)));
try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph));
try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
vt_write(t, register, register.len);
try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
const enabled = true;
try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled)));
vt_write(t, register, register.len);
try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
}
test "get_multi success" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var cols: u16 = 0;
var rows: u16 = 0;
var written: usize = 0;
const keys = [_]TerminalData{ .cols, .rows };
var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&rows) };
try testing.expectEqual(Result.success, get_multi(t, keys.len, &keys, &values, &written));
try testing.expectEqual(keys.len, written);
try testing.expectEqual(80, cols);
try testing.expectEqual(24, rows);
}
test "get_multi error sets out_written" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var cols: u16 = 0;
var written: usize = 99;
const keys = [_]TerminalData{ .cols, .invalid };
var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&cols) };
try testing.expectEqual(Result.invalid_value, get_multi(t, keys.len, &keys, &values, &written));
try testing.expectEqual(1, written);
try testing.expectEqual(80, cols);
}
test "get_multi null keys returns invalid_value" {
var cols: u16 = 0;
var values = [_]?*anyopaque{@ptrCast(&cols)};
try testing.expectEqual(Result.invalid_value, get_multi(null, 1, null, &values, null));
}