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_image = @import("../kitty/graphics_image.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 search_c = @import("search.zig");
const selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
const clipboard = @import("../clipboard.zig");
const kitty_clipboard = @import("../kitty/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.
///
/// This is TinyIo on every target: it is stateless and supports exactly
/// the operations the terminal needs at a fraction of the code size of
/// `std.Io.Threaded` (see lib/TinyIo.zig), on POSIX and Windows alike.
/// On the remaining targets (e.g. freestanding wasm) TinyIo degrades to
/// `std.Io.failing`, which is correct: they have no filesystem.
pub const Io = struct {
impl: lib.TinyIo,
pub const init: Io = .{ .impl = .init };
/// Return the value passed to native terminal construction and decoding.
pub fn io(self: Io) std.Io {
return self.impl.io();
}
};
/// 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`.
io: Io,
/// Allocator-owned copy of the temporary directory path for some
/// operations (e.g. kitty graphics). This is only allocated once the
/// embedder sets the option.
tmp_dir_path: ?[]u8 = null,
/// The terminfo name reported for XTGETTCAP "TN". The stream handler holds
/// a slice into this.
terminfo_name_buf: [Handler.max_terminfo_name_bytes]u8,
stream: Stream,
effects: Effects = .{},
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
searches: std.AutoArrayHashMapUnmanaged(*search_c.SearchWrapper, 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.
/// The embedder answers by calling `reply` with the request before the
/// callback returns.
///
/// C: GhosttyClipboardWrite
pub const ClipboardWrite = extern struct {
size: usize,
location: clipboard.Location,
contents: ?[*]const ClipboardContent,
contents_len: usize,
name: lib.String,
granted: bool,
can_remember: bool,
/// Terminal-owned reply state; opaque to the embedder.
ctx: *const anyopaque,
reply: ClipboardWriteReplyFn,
};
/// The reply to a clipboard write request.
///
/// C: GhosttyClipboardWriteReply
pub const ClipboardWriteReply = extern struct {
size: usize,
result: clipboard.Write.Status,
remember: bool,
};
/// C function pointer type for replying to a clipboard write.
///
/// C: GhosttyClipboardWriteReplyFn
pub const ClipboardWriteReplyFn = *const fn (*const ClipboardWrite, *const ClipboardWriteReply) callconv(lib.calling_conv) void;
/// The reply to a clipboard read request.
///
/// C: GhosttyClipboardReadReply
pub const ClipboardReadReply = extern struct {
size: usize,
result: clipboard.Read.Status,
contents: ?[*]const ClipboardContent,
contents_len: usize,
available: ?[*]const lib.String,
available_len: usize,
remember: bool,
};
/// A synchronous request to read clipboard contents. The embedder answers
/// by calling `reply` with the request before the callback returns.
///
/// C: GhosttyClipboardRead
pub const ClipboardRead = extern struct {
size: usize,
location: clipboard.Location,
mimes: ?[*]const lib.String,
mimes_len: usize,
list: bool,
name: lib.String,
granted: bool,
can_remember: bool,
/// Terminal-owned reply state; opaque to the embedder.
ctx: *const anyopaque,
reply: ClipboardReadReplyFn,
};
/// C function pointer type for replying to a clipboard read.
///
/// C: GhosttyClipboardReadReplyFn
pub const ClipboardReadReplyFn = *const fn (*const ClipboardRead, *const ClipboardReadReply) callconv(lib.calling_conv) void;
/// 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 borrowed unsupported string sequence.
///
/// C: GhosttyTerminalUnknownStringSequence
pub const UnknownStringSequence = extern struct {
truncated: bool,
content: lib.String,
};
/// An unsupported terminal sequence reported to the C callback.
///
/// C: GhosttyTerminalUnknownSequence
pub const UnknownSequence = union(Tag) {
apc: UnknownStringSequence,
/// C: GhosttyTerminalUnknownSequenceTag
pub const Tag = lib.Enum(lib.target, &.{"apc"});
const c_union = lib.TaggedUnion(
lib.target,
@This(),
// A future borrowed CSI payload may need parameter, separator, and
// intermediate arrays. Reserve 128 bytes so that representation and
// other structured sequence types can be added without an ABI break.
.{ .padding = [16]u64 },
);
pub const C = c_union.C;
pub const CValue = c_union.CValue;
pub const cval = c_union.cval;
};
/// 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. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
/// the corresponding callback is null. The unknown-sequence and
/// clipboard trampolines are installed dynamically to preserve their
/// null fast paths (for clipboard_write, a null Zig-level effect makes
/// Kitty clipboard writes fail up front instead of spooling a
/// transaction that can never commit; for clipboard_read it keeps
/// reads denied).
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,
clipboard_read: ?ClipboardReadFn = null,
unknown_sequence: ?UnknownSequenceFn = null,
snapshot_file: ?SnapshotFileFn = 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,
pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool;
fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool {
const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?));
const callback = wrapper.effects.snapshot_file orelse return false;
return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out);
}
/// 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
/// is borrowed for the callback duration and must be answered through
/// its reply function before the callback returns.
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) void;
/// C function pointer type for the clipboard_read callback. The request
/// is borrowed for the callback duration and must be answered through
/// its reply function before the callback returns.
pub const ClipboardReadFn = *const fn (Terminal, ?*anyopaque, *const ClipboardRead) callconv(lib.calling_conv) void;
/// 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 unknown_sequence callback. The request
/// and its content are borrowed for the callback duration.
pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) void;
/// C function pointer type for the size callback. Used by XTWINOPS queries
/// and VT-driven mode 2048 enable reports. Returns true and fills out_size
/// if size is available, or false to suppress the XTWINOPS response or
/// mode 2048 report.
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: []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);
}
/// Opaque context behind ClipboardWrite.ctx for the reply trampoline.
const ClipboardWriteCtx = struct {
write: clipboard.Write,
};
fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.clipboard_write orelse
return write.reply(.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 write.reply(.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 = .init(content.mime),
.data = .init(content.data),
};
}
const ctx: ClipboardWriteCtx = .{ .write = write };
const request: ClipboardWrite = .{
.size = @sizeOf(ClipboardWrite),
.location = write.location,
.contents = if (contents.len > 0) contents.ptr else null,
.contents_len = contents.len,
.name = .init(write.name),
.granted = write.granted,
.can_remember = write.can_remember,
.ctx = &ctx,
.reply = &clipboardWriteReplyTrampoline,
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn clipboardWriteReplyTrampoline(
request: *const ClipboardWrite,
reply: *const ClipboardWriteReply,
) callconv(lib.calling_conv) void {
const ctx: *const ClipboardWriteCtx = @ptrCast(@alignCast(request.ctx));
const write = ctx.write;
switch (reply.result) {
.success => write.reply(.{ .success = .{ .remember = reply.remember } }),
.denied => write.reply(.denied),
.busy => write.reply(.busy),
.invalid_data => write.reply(.invalid_data),
.io_error => write.reply(.io_error),
.unsupported, _ => write.reply(.unsupported),
}
}
/// Opaque context behind ClipboardRead.ctx for the reply trampoline.
const ClipboardReadCtx = struct {
read: clipboard.Read,
wrapper: *TerminalWrapper,
};
fn clipboardReadTrampoline(handler: *Handler, read: clipboard.Read) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.clipboard_read orelse return;
// Requests carry a handful of MIME types, so keep the common case
// allocation-free. On OOM the request goes unanswered and the
// handler replies with an empty clipboard.
var sfa = std.heap.stackFallback(128, wrapper.terminal.gpa());
const alloc = sfa.get();
const mimes = alloc.alloc(lib.String, read.mimes.len) catch {
log.warn("out of memory converting clipboard read request", .{});
return;
};
defer alloc.free(mimes);
for (mimes, read.mimes) |*c_mime, mime| c_mime.* = .init(mime);
const ctx: ClipboardReadCtx = .{ .read = read, .wrapper = wrapper };
const request: ClipboardRead = .{
.size = @sizeOf(ClipboardRead),
.location = read.location,
.mimes = if (mimes.len > 0) mimes.ptr else null,
.mimes_len = mimes.len,
.list = read.list,
.name = .init(read.name),
.granted = read.granted,
.can_remember = read.can_remember,
.ctx = &ctx,
.reply = &clipboardReadReplyTrampoline,
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn clipboardReadReplyTrampoline(
request: *const ClipboardRead,
reply: *const ClipboardReadReply,
) callconv(lib.calling_conv) void {
const ctx: *const ClipboardReadCtx = @ptrCast(@alignCast(request.ctx));
const read = ctx.read;
switch (reply.result) {
.success => {},
.denied => return read.reply(.denied),
.busy => return read.reply(.busy),
.io_error => return read.reply(.io_error),
.unsupported, _ => return read.reply(.unsupported),
}
const c_contents: []const ClipboardContent = if (reply.contents) |ptr|
ptr[0..reply.contents_len]
else
&.{};
const c_available: []const lib.String = if (reply.available) |ptr|
ptr[0..reply.available_len]
else
&.{};
// Most replies carry one representation, so keep that path
// allocation-free while supporting arbitrary multi-MIME replies.
// On OOM we don't reply and the handler answers with an empty
// clipboard.
var sfa = std.heap.stackFallback(256, ctx.wrapper.terminal.gpa());
const alloc = sfa.get();
const contents = alloc.alloc(clipboard.Content, c_contents.len) catch {
log.warn("out of memory converting clipboard read reply", .{});
return;
};
defer alloc.free(contents);
for (contents, c_contents) |*content, c_content| {
content.* = .{
.mime = c_content.mime.ptr[0..c_content.mime.len],
.data = c_content.data.ptr[0..c_content.data.len],
};
}
const available = alloc.alloc([]const u8, c_available.len) catch {
log.warn("out of memory converting clipboard read reply", .{});
return;
};
defer alloc.free(available);
for (available, c_available) |*mime, c_mime| {
mime.* = c_mime.ptr[0..c_mime.len];
}
read.reply(.{ .success = .{
.contents = contents,
.available = available,
.remember = reply.remember,
} });
}
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 = .init(notification.title),
.body = .init(notification.body),
};
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 unknownSequenceTrampoline(
handler: *Handler,
sequence: Handler.UnknownSequence,
) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.unknown_sequence orelse return;
const value = UnknownSequence.cval(switch (sequence) {
.apc => |apc_value| .{
.apc = .{
.truncated = apc_value.truncated,
.content = .init(apc_value.content),
},
},
});
func(@ptrCast(wrapper), wrapper.effects.userdata, &value);
}
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,
.drag_and_drop = null,
.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,
// Installed dynamically when the callback is set; see Effects.
.clipboard_write = null,
.clipboard_read = null,
};
wrapper.* = .{
.terminal = t,
.io = io,
.terminfo_name_buf = 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.
///
/// `io` is the owner the decoded terminal was built with and is retained
/// by the returned wrapper. The decoded terminal is transferred only
/// after its final heap address has been allocated; its
/// continuation remains in `decoded` and is replayed before returning.
/// `continuation_max_bytes` selects the returned terminal's tracking policy:
/// zero uses a temporary exact-size tracker and restores the ordinary C
/// default before returning, while a nonzero value leaves tracking enabled
/// with that limit.
pub fn fromDecoded(
alloc: std.mem.Allocator,
io: Io,
decoded: *snapshot_core.Decoded,
continuation_max_bytes: usize,
) FromDecodedError!Terminal {
const native = alloc.create(ZigTerminal) catch
return error.OutOfMemory;
native.* = decoded.toOwned();
const continuation = switch (decoded.continuation) {
.ground => "",
.bytes => |bytes| bytes,
};
assert(continuation_max_bytes == 0 or
continuation.len <= continuation_max_bytes);
// Without opt-in retention, non-ground state needs tracking only long
// enough to verify that replay reconstructed the exact canonical
// continuation. With retention, even ground state needs a tracker so its
// continuation can be exported successfully as an empty slice.
const tracker_max_bytes = if (continuation_max_bytes > 0)
continuation_max_bytes
else
continuation.len;
const terminal = wrap(alloc, native, io, tracker_max_bytes) catch |err| {
native.deinit(alloc);
alloc.destroy(native);
return err;
};
errdefer free(terminal);
if (continuation.len > 0) {
restoreContinuation(terminal, continuation) catch |err| return switch (err) {
// The decoded bytes 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,
};
}
// Unless the decoder opted into retention, restore the same disabled
// tracking default used by newly created C terminals. Disabling tracking
// does not alter the parser or UTF-8 state reconstructed above.
if (continuation_max_bytes == 0) {
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: Io = .init;
// 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 fn vt_write_until_ground(
terminal_: Terminal,
ptr_: ?[*]const u8,
len: usize,
out_consumed_: ?*usize,
) callconv(lib.calling_conv) Result {
const out_consumed = out_consumed_ orelse return .invalid_value;
out_consumed.* = 0;
const wrapper = terminal_ orelse return .invalid_value;
const input: []const u8 = if (ptr_) |ptr|
ptr[0..len]
else if (len == 0)
""
else
return .invalid_value;
if (wrapper.stream.nextSliceUntilGround(input)) |consumed| {
out_consumed.* = consumed;
return .success;
}
out_consumed.* = len;
return .no_value;
}
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 buffer: [c_io.WriterAdapter.recommended_buffer_len]u8 = undefined;
var adapter: c_io.WriterAdapter = .initBuffered(writer, &buffer);
write: {
continuationWriteIo(terminal_, &adapter.interface) catch |err| switch (err) {
error.WriteFailed => break :write,
else => return continuationErrorResult(err),
};
adapter.interface.flush() catch break :write;
return .success;
}
if (adapter.invalid_write) return .limit_exceeded;
if (adapter.callback_failed) return .io_error;
return continuationErrorResult(error.WriteFailed);
}
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.
// An idle parser has no continuation. Never export the empty Zig slice's
// sentinel pointer to a foreign runtime.
out_ptr.* = if (bytes.len == 0) null else 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,
unknown_sequence = 35,
unknown_max_bytes = 36,
terminfo_name = 37,
clipboard_read = 38,
clipboard_write_max_bytes = 39,
kitty_image_preserve_png = 40,
kitty_image_snapshot_file = 41,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.write_pty => ?Effects.WritePtyFn,
.kitty_image_snapshot_file => ?Effects.SnapshotFileFn,
.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,
.clipboard_read => ?Effects.ClipboardReadFn,
.unknown_sequence => ?Effects.UnknownSequenceFn,
.title, .pwd, .terminfo_name => ?*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_preserve_png,
.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,
.unknown_max_bytes,
.clipboard_write_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,
.kitty_image_snapshot_file => {
if (comptime !build_options.kitty_graphics) return .success;
wrapper.effects.snapshot_file = value;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null)
.{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile }
else
null;
}
},
.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;
wrapper.stream.handler.effects.clipboard_write = if (value != null)
&Effects.clipboardWriteTrampoline
else
null;
},
.clipboard_read => {
wrapper.effects.clipboard_read = value;
wrapper.stream.handler.effects.clipboard_read = if (value != null)
&Effects.clipboardReadTrampoline
else
null;
},
.unknown_sequence => {
wrapper.effects.unknown_sequence = value;
wrapper.stream.handler.unknown_sequence = if (value != null)
&Effects.unknownSequenceTrampoline
else
null;
},
.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;
},
.terminfo_name => {
const str = if (value) |v| v.ptr[0..v.len] else "";
if (str.len > wrapper.terminfo_name_buf.len) return .invalid_value;
@memcpy(wrapper.terminfo_name_buf[0..str.len], str);
wrapper.stream.handler.terminfo_name = if (str.len > 0)
wrapper.terminfo_name_buf[0..str.len]
else
null;
},
.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(
wrapper.terminal.gpa(),
if (value) |v| color.paletteZval(v) else color.default,
) catch return .out_of_memory;
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_preserve_png,
.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_preserve_png => screen.kitty_images.image_limits.preserve_png = val,
.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;
const alloc = wrapper.terminal.gpa();
if (value) |v| {
if (v.len > max_path_bytes) return .out_of_memory;
const path = alloc.dupe(u8, v.ptr[0..v.len]) catch
return .out_of_memory;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.image_limits.temporary_file = .{
.enabled = .{ .directory = path },
};
}
// Every screen points at the new copy now so the previous
// one can be released.
if (wrapper.tmp_dir_path) |old| alloc.free(old);
wrapper.tmp_dir_path = path;
} else {
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.image_limits.temporary_file = .disabled;
}
if (wrapper.tmp_dir_path) |old| alloc.free(old);
wrapper.tmp_dir_path = null;
}
},
.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,
),
.unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes =
if (value) |ptr| ptr.* else 0,
.clipboard_write_max_bytes => wrapper.stream.handler.kitty_clipboard_write_max_bytes =
if (value) |ptr| ptr.* else kitty_clipboard.max_write_size,
.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();
}
/// Clear output without feeding synthetic bytes into the child's VT stream.
pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool {
const t: *ZigTerminal = (terminal_ orelse return false).terminal;
if (t.screens.active_key == .alternate) return false;
const screen = t.screens.active;
var first = screen.cursor.y;
while (first > 0) {
const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?;
if (!pin.rowAndCell().row.wrap_continuation) break;
first -= 1;
}
var last = screen.cursor.y;
while (last + 1 < t.rows) {
const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?;
if (!pin.rowAndCell().row.wrap) break;
last += 1;
}
screen.clearSelection();
if (last + 1 < t.rows) {
screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false);
}
screen.eraseHistory(null);
if (first > 0) screen.eraseActive(first - 1);
screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false;
screen.scroll(.active);
if (comptime build_options.kitty_graphics) {
screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true });
}
t.flags.dirty.clear = true;
return true;
}
/// 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,
vt_ground = 38,
cursor_at_prompt = 39,
clipboard_write_max_bytes = 40,
modify_other_keys = 41,
/// 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,
.vt_ground,
.cursor_at_prompt,
.modify_other_keys,
=> 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,
.clipboard_write_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.* = .init(dir);
},
.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,
.modify_other_keys => out.* = t.flags.modify_other_keys_2,
.vt_processing_error => out.* = wrapper.stream.handler.semantic_failure,
.vt_ground => out.* = wrapper.stream.ground(),
.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),
.clipboard_write_max_bytes => out.* = wrapper.stream.handler.kitty_clipboard_write_max_bytes,
.mode => {
const mode = out.toMode() orelse return .invalid_value;
out.value = t.modes.get(mode);
},
.cursor_at_prompt => out.* = t.cursorIsAtPrompt(),
}
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);
for (wrapper.searches.keys()) |search| search.terminal = null;
wrapper.searches.deinit(alloc);
wrapper.stream.deinit();
t.deinit(alloc);
if (wrapper.tmp_dir_path) |path| alloc.free(path);
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)[0..out_len];
defer lib.alloc.default(&lib.alloc.test_allocator).free(allocated);
try testing.expectEqualStrings("\x1b[31", allocated);
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);
// Completing the sequence must return an empty, allocation-free result.
const failing: CAllocator = .fromZig(&std.mem.Allocator.failing);
try testing.expectEqual(Result.success, continuation_alloc(t, &failing, &out_ptr, &out_len));
defer @import("allocator.zig").free(&failing, out_ptr, out_len);
try testing.expectEqual(null, out_ptr);
try testing.expectEqual(@as(usize, 0), out_len);
}
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_until_ground result contract" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
// Input remains untouched when the stream is already at ground.
var consumed: usize = 99;
const untouched = "unprocessed";
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, untouched, untouched.len, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
// Complete a split CSI and stop before inspecting the printable suffix.
vt_write(t, "\x1b[31", 4);
const input = "mABC\x1b[";
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, input, input.len, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
var str = try t.?.terminal.plainString(testing.allocator);
try testing.expectEqualStrings("", str);
testing.allocator.free(str);
// The untouched suffix can be processed after work at the boundary.
vt_write(t, input.ptr + consumed, input.len - consumed);
str = try t.?.terminal.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("ABC", str);
try testing.expect(!t.?.stream.ground());
// Exhausting input while unfinished is distinct from reaching ground on
// the final byte, even though both consume the entire input.
try testing.expectEqual(
Result.no_value,
vt_write_until_ground(t, "123", 3, &consumed),
);
try testing.expectEqual(@as(usize, 3), consumed);
try testing.expect(!t.?.stream.ground());
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, "m", 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
}
test "vt_write_until_ground handles UTF-8 and abort boundaries" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var ground: bool = false;
try testing.expectEqual(
Result.success,
get(t, .vt_ground, @ptrCast(&ground)),
);
try testing.expect(ground);
vt_write(t, &.{0xF0}, 1);
try testing.expectEqual(
Result.success,
get(t, .vt_ground, @ptrCast(&ground)),
);
try testing.expect(!ground);
const utf8_suffix = [_]u8{ 0x9F, 0x98, 0x84 };
var consumed: usize = 99;
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, &utf8_suffix, utf8_suffix.len, &consumed),
);
try testing.expectEqual(utf8_suffix.len, consumed);
try testing.expect(t.?.stream.ground());
// A malformed continuation resets the decoder and reaches ground after
// processing the retry byte.
vt_write(t, &.{ 0xE0, 0xA0 }, 2);
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, "A", 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
vt_write(t, "\x1b[123", 5);
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, &.{0x18}, 1, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expect(t.?.stream.ground());
}
test "vt_write_until_ground invokes effects only for consumed input" {
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;
fn bell(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) void {
bell_count += 1;
}
};
S.bell_count = 0;
try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell)));
vt_write(t, "\x1b[31", 4);
const input = "m\x07";
var consumed: usize = 99;
try testing.expectEqual(
Result.success,
vt_write_until_ground(t, input, input.len, &consumed),
);
try testing.expectEqual(@as(usize, 1), consumed);
try testing.expectEqual(@as(usize, 0), S.bell_count);
vt_write(t, input.ptr + consumed, input.len - consumed);
try testing.expectEqual(@as(usize, 1), S.bell_count);
}
test "vt_write_until_ground validates arguments" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var consumed: usize = 99;
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(null, "x", 1, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
consumed = 99;
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(t, null, 1, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
try testing.expectEqual(
Result.invalid_value,
vt_write_until_ground(t, "", 0, null),
);
// NULL represents a valid empty slice. While unfinished it consumes zero
// bytes and reports that no ground boundary was found.
vt_write(t, "\x1b[", 2);
consumed = 99;
try testing.expectEqual(
Result.no_value,
vt_write_until_ground(t, null, 0, &consumed),
);
try testing.expectEqual(@as(usize, 0), consumed);
}
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, "xå", 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 cursor_at_prompt" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
var at_prompt: bool = undefined;
try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt)));
try testing.expect(!at_prompt);
const prompt_start = "\x1b]133;A\x07";
vt_write(t, prompt_start, prompt_start.len);
try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt)));
try testing.expect(at_prompt);
const alternate_screen = "\x1b[?1049h";
vt_write(t, alternate_screen, alternate_screen.len);
try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt)));
try testing.expect(!at_prompt);
}
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 modify_other_keys" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(&lib.alloc.test_allocator, &t, 80, 24));
defer free(t);
var enabled: bool = undefined;
try testing.expectEqual(Result.success, get(t, .modify_other_keys, @ptrCast(&enabled)));
try testing.expect(!enabled);
vt_write(t, "\x1b[>4;2m", 7);
try testing.expectEqual(Result.success, get(t, .modify_other_keys, @ptrCast(&enabled)));
try testing.expect(enabled);
vt_write(t, "\x1b[>4;0m", 7);
try testing.expectEqual(Result.success, get(t, .modify_other_keys, @ptrCast(&enabled)));
try testing.expect(!enabled);
}
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 terminfo_name option" {
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)));
// While no name is set the query goes unanswered; other capabilities
// are still served from the static map.
const query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\";
vt_write(t, query, query.len);
try testing.expect(S.last_data == null);
const co_query = "\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\";
vt_write(t, co_query, co_query.len);
try testing.expectEqualStrings(
"\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++
std.fmt.bytesToHex("256", .upper) ++ "\x1B\\",
S.last_data.?,
);
S.deinit();
// The name is copied, so the caller's buffer can go away afterwards.
var name: [14]u8 = "xterm-256color".*;
const value: lib.String = .{ .ptr = &name, .len = name.len };
try testing.expectEqual(Result.success, set(t, .terminfo_name, @ptrCast(&value)));
@memset(&name, 'z');
vt_write(t, query, query.len);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings(
"\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++
std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\",
S.last_data.?,
);
// Clearing with NULL leaves the query unanswered again.
S.deinit();
try testing.expectEqual(Result.success, set(t, .terminfo_name, null));
vt_write(t, query, query.len);
try testing.expect(S.last_data == null);
// Names beyond the maximum are rejected rather than truncated.
const long: [Handler.max_terminfo_name_bytes + 1]u8 = @splat('a');
const long_value: lib.String = .{ .ptr = &long, .len = long.len };
try testing.expectEqual(Result.invalid_value, set(t, .terminfo_name, @ptrCast(&long_value)));
}
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 "kitty_image_medium_temp_file empty output" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: Terminal = null;
try testing.expectEqual(Result.success, new(&lib.alloc.test_allocator, &t, 80, 24));
defer free(t);
const empty: lib.String = .{ .ptr = "", .len = 0 };
try testing.expectEqual(Result.success, set(t, .kitty_image_medium_temp_file, &empty));
var result: lib.String = undefined;
try testing.expectEqual(Result.success, get(t, .kitty_image_medium_temp_file, &result));
try testing.expectEqual(@as(usize, 0), result.len);
try testing.expectEqual(@as([*]const u8, ""), result.ptr);
}
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 unknown_sequence 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_tag: UnknownSequence.Tag = .apc;
var last_truncated: bool = false;
var content: [64]u8 = undefined;
var content_len: usize = 0;
fn unknownSequence(
terminal_: Terminal,
ud: ?*anyopaque,
sequence: *const UnknownSequence.C,
) callconv(lib.calling_conv) void {
count += 1;
last_terminal = terminal_;
last_userdata = ud;
last_tag = sequence.tag;
const apc_value = sequence.value.apc;
last_truncated = apc_value.truncated;
content_len = @min(apc_value.content.len, content.len);
@memcpy(content[0..content_len], apc_value.content.ptr[0..content_len]);
}
};
S.count = 0;
S.last_terminal = null;
S.last_userdata = null;
S.last_tag = .apc;
S.last_truncated = false;
S.content_len = 0;
var sentinel: u8 = 101;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
const max_bytes: usize = 8;
try testing.expectEqual(Result.success, set(
t,
.unknown_max_bytes,
@ptrCast(&max_bytes),
));
try testing.expectEqual(max_bytes, t.?.stream.handler.apc_handler.unknown_max_bytes);
// A byte limit without a callback performs no external effect.
const before_callback = "\x1B_abc;xy\x1B\\";
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 0), S.count);
try testing.expect(t.?.stream.handler.unknown_sequence == null);
try testing.expectEqual(Result.success, set(
t,
.unknown_sequence,
@ptrCast(&S.unknownSequence),
));
try testing.expect(t.?.stream.handler.unknown_sequence != null);
// Split a complete APC across writes to exercise persistent parser state.
const seq_a = "\x1B_abc;";
const seq_b = "xy\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(t, S.last_terminal);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(UnknownSequence.Tag.apc, S.last_tag);
try testing.expect(!S.last_truncated);
try testing.expectEqualStrings("abc;xy", S.content[0..S.content_len]);
// Content beyond the generic limit is omitted and marked truncated.
const truncated = "\x1B_abcdefghijkl\x1B\\";
vt_write(t, truncated, truncated.len);
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expect(S.last_truncated);
try testing.expectEqualStrings("abcdefgh", S.content[0..S.content_len]);
// CAN aborts the APC and must not invoke the callback.
const aborted = "\x1B_abcdef\x18";
vt_write(t, aborted, aborted.len);
try testing.expectEqual(@as(usize, 2), S.count);
// Clearing the callback restores the null fast path immediately.
try testing.expectEqual(Result.success, set(t, .unknown_sequence, null));
try testing.expect(t.?.stream.handler.unknown_sequence == null);
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 2), S.count);
// A NULL limit disables capture even after reinstalling the callback.
try testing.expectEqual(Result.success, set(
t,
.unknown_sequence,
@ptrCast(&S.unknownSequence),
));
try testing.expectEqual(Result.success, set(t, .unknown_max_bytes, null));
try testing.expectEqual(@as(usize, 0), t.?.stream.handler.apc_handler.unknown_max_bytes);
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 2), 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 last_name_len: usize = 0;
var last_granted: bool = true;
var last_can_remember: bool = true;
var next_result: clipboard.Write.Status = .success;
fn clipboardWrite(
terminal_: Terminal,
ud: ?*anyopaque,
request: *const ClipboardWrite,
) callconv(lib.calling_conv) void {
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;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
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]],
);
}
}
request.reply(request, &.{
.size = @sizeOf(ClipboardWriteReply),
.result = next_result,
.remember = false,
});
}
};
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 carries no program identity or password grant state.
try testing.expectEqual(@as(usize, 0), S.last_name_len);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
// 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 replies propagate
// back 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 = "plain" },
.{ .mime = "text/rtf", .data = "{\\rtf1 plain}" },
.{ .mime = "image/png", .data = "\x89PNG" },
};
const Reply = struct {
var last: ?clipboard.Write.Result = null;
fn reply(_: *anyopaque, result: clipboard.Write.Result) void {
last = result;
}
};
S.next_result = .busy;
const handler = &t.?.stream.handler;
handler.effects.clipboard_write.?(handler, .{
.location = .primary,
.contents = &internal_contents,
.name = "app",
.granted = true,
.can_remember = true,
.reply_ctx = handler,
.reply_fn = &Reply.reply,
});
try testing.expect(Reply.last.? == .busy);
try testing.expectEqual(@as(usize, 3), S.last_name_len);
try testing.expect(S.last_granted);
try testing.expect(S.last_can_remember);
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 and uninstalls
// the trampoline.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
try testing.expect(t.?.stream.handler.effects.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);
// No trampoline is installed until a callback is set, so the
// stream skips clipboard work (and never spools a Kitty clipboard
// transaction it can't deliver).
try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
}
test "kitty clipboard write via C effects" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var responses: [512]u8 = undefined;
var responses_len: usize = 0;
var write_count: usize = 0;
var last_location: clipboard.Location = .standard;
var last_contents_len: usize = 0;
var last_mimes: [4][64]u8 = undefined;
var last_mime_lens: [4]usize = @splat(0);
var last_data: [4][64]u8 = undefined;
var last_data_lens: [4]usize = @splat(0);
var last_granted: bool = true;
var last_can_remember: bool = true;
var next_remember: bool = false;
fn writePty(
_: Terminal,
_: ?*anyopaque,
ptr: [*]const u8,
len: usize,
) callconv(lib.calling_conv) void {
@memcpy(responses[responses_len..][0..len], ptr[0..len]);
responses_len += len;
}
fn clipboardWrite(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardWrite,
) callconv(lib.calling_conv) void {
write_count += 1;
last_location = request.location;
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]],
);
}
}
last_granted = request.granted;
last_can_remember = request.can_remember;
request.reply(request, &.{
.size = @sizeOf(ClipboardWriteReply),
.result = .success,
.remember = next_remember,
});
}
};
S.responses_len = 0;
S.write_count = 0;
S.last_mime_lens = @splat(0);
S.last_data_lens = @splat(0);
S.last_granted = true;
S.last_can_remember = true;
S.next_remember = false;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
// A full OSC 5522 write transaction: begin, chunked data for two
// representations, commit. Only the commit invokes the callback,
// and its result maps to the DONE response.
const seqs = [_][]const u8{
"\x1B]5522;type=write:id=c1\x1B\\",
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost"
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty"
"\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "hi"
"\x1B]5522;type=wdata\x1B\\",
};
for (seqs) |seq| vt_write(t, seq.ptr, seq.len);
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqual(@as(usize, 2), S.last_contents_len);
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
try testing.expectEqualStrings("Ghostty", S.last_data[0][0..S.last_data_lens[0]]);
try testing.expectEqualStrings("text/html", S.last_mimes[1][0..S.last_mime_lens[1]]);
try testing.expectEqualStrings("hi", S.last_data[1][0..S.last_data_lens[1]]);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=c1\x1B\\",
S.responses[0..S.responses_len],
);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
// Password grants round-trip through the C reply: the first pw'd
// commit isn't granted and asks to remember, so the next one
// arrives granted.
S.responses_len = 0;
S.next_remember = true;
const grant_seqs = [_][]const u8{
"\x1B]5522;type=write:id=g1:pw=c2VjcmV0:name=YXBw\x1B\\",
"\x1B]5522;type=wdata\x1B\\",
"\x1B]5522;type=write:id=g2:pw=c2VjcmV0:name=YXBw\x1B\\",
"\x1B]5522;type=wdata\x1B\\",
};
for (grant_seqs) |seq| vt_write(t, seq.ptr, seq.len);
try testing.expectEqual(@as(usize, 3), S.write_count);
try testing.expect(S.last_granted);
try testing.expect(S.last_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=g1\x1B\\" ++
"\x1B]5522;type=write:status=DONE:id=g2\x1B\\",
S.responses[0..S.responses_len],
);
S.next_remember = false;
// Without a read callback reads are denied.
S.responses_len = 0;
const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read, read.len);
try testing.expectEqual(@as(usize, 3), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=r1\x1B\\",
S.responses[0..S.responses_len],
);
// With a read callback the request is served through it.
const R = struct {
var count: usize = 0;
var last_mimes_len: usize = 0;
var last_mime_is_text: bool = false;
var last_list: bool = true;
var last_name_len: usize = 0;
var last_granted: bool = true;
var last_can_remember: bool = true;
fn clipboardRead(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardRead,
) callconv(lib.calling_conv) void {
count += 1;
last_mimes_len = request.mimes_len;
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
u8,
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
"text/plain",
);
last_list = request.list;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
const mime: []const u8 = "text/plain";
const data: []const u8 = "hello";
const contents = [_]ClipboardContent{.{
.mime = .init(mime),
.data = .init(data),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = .success,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
};
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
S.responses_len = 0;
// name="app" without a password: forwarded for prompts, not
// rememberable.
const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read2, read2.len);
try testing.expectEqual(@as(usize, 1), R.count);
try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
try testing.expect(R.last_mime_is_text);
try testing.expect(!R.last_list);
try testing.expectEqual(@as(usize, 3), R.last_name_len);
try testing.expect(!R.last_granted);
try testing.expect(!R.last_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
S.responses[0..S.responses_len],
);
// Without a clipboard callback the transaction fails up front.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
S.responses_len = 0;
const begin = "\x1B]5522;type=write:id=c2\x1B\\";
vt_write(t, begin, begin.len);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\",
S.responses[0..S.responses_len],
);
}
test "set clipboard write max bytes" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var responses: [512]u8 = undefined;
var responses_len: usize = 0;
var write_count: usize = 0;
fn writePty(
_: Terminal,
_: ?*anyopaque,
ptr: [*]const u8,
len: usize,
) callconv(lib.calling_conv) void {
@memcpy(responses[responses_len..][0..len], ptr[0..len]);
responses_len += len;
}
fn clipboardWrite(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardWrite,
) callconv(lib.calling_conv) void {
write_count += 1;
request.reply(request, &.{
.size = @sizeOf(ClipboardWriteReply),
.result = .success,
.remember = false,
});
}
};
S.responses_len = 0;
S.write_count = 0;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
// The built-in default reads back.
var max: usize = 0;
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max);
// Set a tiny limit; an oversized text write fails with EFBIG and
// never reaches the callback.
const limit: usize = 4;
try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, @ptrCast(&limit)));
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
try testing.expectEqual(limit, max);
const seqs = [_][]const u8{
"\x1B]5522;type=write:id=c1\x1B\\",
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbA==\x1B\\", // "Hell"
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bw==\x1B\\", // "o"
"\x1B]5522;type=wdata\x1B\\",
};
for (seqs) |seq| vt_write(t, seq.ptr, seq.len);
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EFBIG:id=c1\x1B\\",
S.responses[0..S.responses_len],
);
// A NULL value reverts to the built-in default.
try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, null));
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max);
}
test "set clipboard_read 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 count: usize = 0;
var last_size: usize = 0;
var last_location: clipboard.Location = .standard;
var last_mimes_len: usize = 0;
var last_mime_is_text: bool = false;
var last_list: bool = true;
var last_name_len: usize = 1;
var last_granted: bool = true;
var last_can_remember: bool = true;
var result: clipboard.Read.Status = .success;
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 clipboardRead(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardRead,
) callconv(lib.calling_conv) void {
count += 1;
last_size = request.size;
last_location = request.location;
last_mimes_len = request.mimes_len;
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
u8,
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
"text/plain",
);
last_list = request.list;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
const mime: []const u8 = "text/plain";
const data: []const u8 = "hello";
const contents = [_]ClipboardContent{.{
.mime = .init(mime),
.data = .init(data),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = result,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// Without a callback the handler effect is unset and reads are silent.
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
const read_st = "\x1B]52;c;?\x1B\\";
vt_write(t, read_st, read_st.len);
try testing.expect(S.last_data == null);
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&S.clipboardRead)));
try testing.expect(t.?.stream.handler.effects.clipboard_read != null);
const read_bel = "\x1B]52;p;?\x07";
vt_write(t, read_bel, read_bel.len);
try testing.expectEqual(1, S.count);
try testing.expectEqual(@sizeOf(ClipboardRead), S.last_size);
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqual(1, S.last_mimes_len);
try testing.expect(S.last_mime_is_text);
try testing.expect(!S.last_list);
try testing.expectEqual(0, S.last_name_len);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.last_data.?);
// Denied replies with an empty clipboard.
S.result = .denied;
vt_write(t, read_st, read_st.len);
try testing.expectEqual(2, S.count);
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.last_data.?);
// Clearing the callback uninstalls the handler effect.
try testing.expectEqual(Result.success, set(t, .clipboard_read, null));
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
}
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 "mode 2048 enable and disable use C callbacks" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var data: [128]u8 = undefined;
var len: usize = 0;
var calls: usize = 0;
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, length: usize) callconv(lib.calling_conv) void {
@memcpy(data[0..length], ptr[0..length]);
len = length;
calls += 1;
}
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;
}
};
S.len = 0;
S.calls = 0;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .size_cb, @ptrCast(&S.sizeCb)));
const enable = "\x1B[?2048h";
const disable = "\x1B[?2048l";
vt_write(t, enable, enable.len);
vt_write(t, enable, enable.len);
try testing.expectEqual(@as(usize, 2), S.calls);
try testing.expectEqualStrings("\x1B[48;24;80;384;640t", S.data[0..S.len]);
vt_write(t, disable, disable.len);
try testing.expectEqual(@as(usize, 2), S.calls);
try testing.expect(!t.?.terminal.modes.get(.in_band_size_reports));
}
test "mode 2048 enable tolerates missing C callbacks" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var writes: usize = 0;
var sizes: usize = 0;
fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
writes += 1;
}
fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool {
sizes += 1;
out_size.* = .{
.rows = 24,
.columns = 80,
.cell_width = 8,
.cell_height = 16,
};
return true;
}
};
S.writes = 0;
S.sizes = 0;
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
const sequence = "\x1B[?2048h";
vt_write(t, sequence, sequence.len);
try testing.expectEqual(@as(usize, 0), S.writes);
try testing.expect(t.?.terminal.modes.get(.in_band_size_reports));
var no_write: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&no_write,
80,
24,
));
defer free(no_write);
try testing.expectEqual(Result.success, set(no_write, .size_cb, @ptrCast(&S.sizeCb)));
vt_write(no_write, sequence, sequence.len);
try testing.expectEqual(@as(usize, 1), S.sizes);
try testing.expect(no_write.?.terminal.modes.get(.in_band_size_reports));
}
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" {
if (comptime !build_options.glyph_protocol) return error.SkipZigTest;
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));
}