diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/c | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/c')
24 files changed, 7420 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/c/AGENTS.md b/vendor/libghostty-vt/src/terminal/c/AGENTS.md new file mode 100644 index 00000000..63f7fc6c --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/AGENTS.md @@ -0,0 +1,22 @@ +# libghostty-vt C API + +- C API must be designed with ABI compatibility in mind +- Zig tagged unions must be converted to C ABI compatible unions + via `lib.TaggedUnion`. +- Any functions must be updated all the way through from here to + `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers + in `include/ghostty/vt.h`. +- In `include/ghostty/vt.h`, always sort the header contents by: + (1) macros, (2) forward declarations, (3) types, (4) functions + +## ABI Compatibility + +- Prefer opaque pointers for long-lived objects, such as + `GhosttyTerminal`. +- Structs: + - May contain padding bytes if we're confident we'll never grow + beyond a certain size. + - May use the "sized struct" pattern: an `extern struct` with + `size: usize = @sizeOf(Self)` as the first field. In the C header, + callers use `GHOSTTY_INIT_SIZED` from `types.h` to zero-initialize and + set the size. diff --git a/vendor/libghostty-vt/src/terminal/c/allocator.zig b/vendor/libghostty-vt/src/terminal/c/allocator.zig new file mode 100644 index 00000000..ca698f3c --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/allocator.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; + +/// Allocate a buffer of `len` bytes using the given allocator +/// (or the default allocator if NULL). +/// +/// Returns a pointer to the allocated buffer, or NULL if the +/// allocation failed. +pub fn alloc( + alloc_: ?*const CAllocator, + len: usize, +) callconv(lib.calling_conv) ?[*]u8 { + const allocator = lib.alloc.default(alloc_); + const buf = allocator.alloc(u8, len) catch return null; + return buf.ptr; +} + +/// Free memory that was allocated by a libghostty-vt function. +/// +/// This must be used to free buffers returned by functions like +/// `format_alloc`. Pass the same allocator (or NULL for the default) +/// that was used for the allocation. +pub fn free( + alloc_: ?*const CAllocator, + ptr: ?[*]u8, + len: usize, +) callconv(lib.calling_conv) void { + const mem = ptr orelse return; + const allocator = lib.alloc.default(alloc_); + allocator.free(mem[0..len]); +} + +test "alloc returns non-null" { + const ptr = alloc(&lib.alloc.test_allocator, 16); + try testing.expect(ptr != null); + free(&lib.alloc.test_allocator, ptr, 16); +} + +test "alloc with null allocator" { + const ptr = alloc(null, 8); + try testing.expect(ptr != null); + free(null, ptr, 8); +} + +test "alloc zero length" { + const ptr = alloc(&lib.alloc.test_allocator, 0); + defer free(&lib.alloc.test_allocator, ptr, 0); +} + +test "free null pointer" { + free(&lib.alloc.test_allocator, null, 0); +} + +test "free allocated memory" { + const allocator = lib.alloc.default(&lib.alloc.test_allocator); + const mem = try allocator.alloc(u8, 16); + free(&lib.alloc.test_allocator, mem.ptr, mem.len); +} + +test "free with null allocator" { + // null allocator falls back to the default (test allocator in tests) + const allocator = lib.alloc.default(null); + const mem = try allocator.alloc(u8, 8); + free(null, mem.ptr, mem.len); +} diff --git a/vendor/libghostty-vt/src/terminal/c/build_info.zig b/vendor/libghostty-vt/src/terminal/c/build_info.zig new file mode 100644 index 00000000..bef6760f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/build_info.zig @@ -0,0 +1,162 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const lib = @import("../lib.zig"); +const build_options = @import("terminal_options"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.build_info_c); + +/// C: GhosttyOptimizeMode +pub const OptimizeMode = enum(c_int) { + debug = 0, + release_safe = 1, + release_small = 2, + release_fast = 3, +}; + +/// C: GhosttyBuildInfo +pub const BuildInfo = enum(c_int) { + invalid = 0, + simd = 1, + kitty_graphics = 2, + tmux_control_mode = 3, + optimize = 4, + version_string = 5, + version_major = 6, + version_minor = 7, + version_patch = 8, + version_build = 9, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: BuildInfo) type { + return switch (self) { + .invalid => void, + .simd, .kitty_graphics, .tmux_control_mode => bool, + .optimize => OptimizeMode, + .version_string, .version_build => lib.String, + .version_major, .version_minor, .version_patch => usize, + }; + } +}; + +pub fn get( + data: BuildInfo, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(BuildInfo, @intFromEnum(data)) catch { + log.warn("build_info invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + comptime data: BuildInfo, + out: *data.OutType(), +) Result { + switch (data) { + .invalid => return .invalid_value, + .simd => out.* = build_options.simd, + .kitty_graphics => out.* = build_options.kitty_graphics, + .tmux_control_mode => out.* = build_options.tmux_control_mode, + .optimize => out.* = switch (builtin.mode) { + .Debug => .debug, + .ReleaseSafe => .release_safe, + .ReleaseSmall => .release_small, + .ReleaseFast => .release_fast, + }, + .version_string => out.* = .{ .ptr = build_options.version_string.ptr, .len = build_options.version_string.len }, + .version_major => out.* = build_options.version_major, + .version_minor => out.* = build_options.version_minor, + .version_patch => out.* = build_options.version_patch, + .version_build => { + if (build_options.version_build) |b| { + out.* = .{ .ptr = b.ptr, .len = b.len }; + } else { + out.* = .{ .ptr = "", .len = 0 }; + } + }, + } + + return .success; +} + +test "get simd" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.simd, @ptrCast(&value))); + try testing.expectEqual(build_options.simd, value); +} + +test "get kitty_graphics" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.kitty_graphics, @ptrCast(&value))); + try testing.expectEqual(build_options.kitty_graphics, value); +} + +test "get tmux_control_mode" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.tmux_control_mode, @ptrCast(&value))); + try testing.expectEqual(build_options.tmux_control_mode, value); +} + +test "get optimize" { + const testing = std.testing; + var value: OptimizeMode = undefined; + try testing.expectEqual(Result.success, get(.optimize, @ptrCast(&value))); + try testing.expectEqual(switch (builtin.mode) { + .Debug => .debug, + .ReleaseSafe => .release_safe, + .ReleaseSmall => .release_small, + .ReleaseFast => .release_fast, + }, value); +} + +test "get version_string" { + const testing = std.testing; + var value: lib.String = undefined; + try testing.expectEqual(Result.success, get(.version_string, @ptrCast(&value))); + try testing.expect(value.len > 0); +} + +test "get version_major" { + const testing = std.testing; + var value: usize = undefined; + try testing.expectEqual(Result.success, get(.version_major, @ptrCast(&value))); + try testing.expectEqual(build_options.version_major, value); +} + +test "get version_minor" { + const testing = std.testing; + var value: usize = undefined; + try testing.expectEqual(Result.success, get(.version_minor, @ptrCast(&value))); + try testing.expectEqual(build_options.version_minor, value); +} + +test "get version_patch" { + const testing = std.testing; + var value: usize = undefined; + try testing.expectEqual(Result.success, get(.version_patch, @ptrCast(&value))); + try testing.expectEqual(build_options.version_patch, value); +} + +test "get version_build" { + const testing = std.testing; + var value: lib.String = undefined; + try testing.expectEqual(Result.success, get(.version_build, @ptrCast(&value))); +} + +test "get invalid" { + try std.testing.expectEqual(Result.invalid_value, get(.invalid, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/cell.zig b/vendor/libghostty-vt/src/terminal/c/cell.zig new file mode 100644 index 00000000..c9746da7 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/cell.zig @@ -0,0 +1,178 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const Cell = page.Cell; +const color = @import("../color.zig"); +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyCell +pub const CCell = u64; + +/// C: GhosttyCellContentTag +pub const ContentTag = enum(c_int) { + codepoint = 0, + codepoint_grapheme = 1, + bg_color_palette = 2, + bg_color_rgb = 3, +}; + +/// C: GhosttyCellWide +pub const Wide = enum(c_int) { + narrow = 0, + wide = 1, + spacer_tail = 2, + spacer_head = 3, +}; + +/// C: GhosttyCellSemanticContent +pub const SemanticContent = enum(c_int) { + output = 0, + input = 1, + prompt = 2, +}; + +/// C: GhosttyCellData +pub const CellData = enum(c_int) { + invalid = 0, + + /// The codepoint of the cell (0 if empty or bg-color-only). + /// Output type: uint32_t * (stored as u21, zero-extended) + codepoint = 1, + + /// The content tag describing what kind of content is in the cell. + /// Output type: GhosttyCellContentTag * + content_tag = 2, + + /// The wide property of the cell. + /// Output type: GhosttyCellWide * + wide = 3, + + /// Whether the cell has text to render. + /// Output type: bool * + has_text = 4, + + /// Whether the cell has styling (non-default style). + /// Output type: bool * + has_styling = 5, + + /// The style ID for the cell (for use with style lookups). + /// Output type: uint16_t * + style_id = 6, + + /// Whether the cell has a hyperlink. + /// Output type: bool * + has_hyperlink = 7, + + /// Whether the cell is protected. + /// Output type: bool * + protected = 8, + + /// The semantic content type of the cell (from OSC 133). + /// Output type: GhosttyCellSemanticContent * + semantic_content = 9, + + /// The palette index for the cell's background color. + /// Only valid when content_tag is bg_color_palette. + /// Output type: GhosttyColorPaletteIndex * + color_palette = 10, + + /// The RGB value for the cell's background color. + /// Only valid when content_tag is bg_color_rgb. + /// Output type: GhosttyColorRgb * + color_rgb = 11, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CellData) type { + return switch (self) { + .invalid => void, + .codepoint => u32, + .content_tag => ContentTag, + .wide => Wide, + .has_text, .has_styling, .has_hyperlink, .protected => bool, + .style_id => u16, + .semantic_content => SemanticContent, + .color_palette => u8, + .color_rgb => color.RGB.C, + }; + } +}; + +pub fn get( + cell_: CCell, + data: CellData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CellData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + cell_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + cell_: CCell, + comptime data: CellData, + out: *data.OutType(), +) Result { + const cell: Cell = @bitCast(cell_); + switch (data) { + .invalid => return .invalid_value, + .codepoint => out.* = @intCast(cell.codepoint()), + .content_tag => out.* = @enumFromInt(@intFromEnum(cell.content_tag)), + .wide => out.* = @enumFromInt(@intFromEnum(cell.wide)), + .has_text => out.* = cell.hasText(), + .has_styling => out.* = cell.hasStyling(), + .style_id => out.* = cell.style_id, + .has_hyperlink => out.* = cell.hyperlink, + .protected => out.* = cell.protected, + .semantic_content => out.* = @enumFromInt(@intFromEnum(cell.semantic_content)), + .color_palette => out.* = cell.content.color_palette, + .color_rgb => { + const rgb = cell.content.color_rgb; + out.* = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + }, + } + + return .success; +} + +test "get codepoint" { + const cell: CCell = @bitCast(Cell.init('A')); + var cp: u32 = 0; + try testing.expectEqual(Result.success, get(cell, .codepoint, @ptrCast(&cp))); + try testing.expectEqual(@as(u32, 'A'), cp); +} + +test "get has_text" { + const cell: CCell = @bitCast(Cell.init('A')); + var has: bool = false; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(has); +} + +test "get empty cell" { + const cell: CCell = @bitCast(Cell.init(0)); + var has: bool = true; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(!has); +} + +test "get wide" { + var zig_cell = Cell.init('A'); + zig_cell.wide = .wide; + const cell: CCell = @bitCast(zig_cell); + var w: Wide = .narrow; + try testing.expectEqual(Result.success, get(cell, .wide, @ptrCast(&w))); + try testing.expectEqual(Wide.wide, w); +} diff --git a/vendor/libghostty-vt/src/terminal/c/color.zig b/vendor/libghostty-vt/src/terminal/c/color.zig new file mode 100644 index 00000000..3d244a19 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/color.zig @@ -0,0 +1,13 @@ +const lib = @import("../lib.zig"); +const color = @import("../color.zig"); + +pub fn rgb_get( + c: color.RGB.C, + r: *u8, + g: *u8, + b: *u8, +) callconv(lib.calling_conv) void { + r.* = c.r; + g.* = c.g; + b.* = c.b; +} diff --git a/vendor/libghostty-vt/src/terminal/c/focus.zig b/vendor/libghostty-vt/src/terminal/c/focus.zig new file mode 100644 index 00000000..2c53191d --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/focus.zig @@ -0,0 +1,55 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const terminal_focus = @import("../focus.zig"); +const Result = @import("result.zig").Result; + +pub fn encode( + event: terminal_focus.Event, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + terminal_focus.encode(&writer, event) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + terminal_focus.encode(&discarding.writer, event) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode focus gained" { + var buf: [terminal_focus.max_encode_size]u8 = undefined; + var written: usize = 0; + const result = encode(.gained, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[I", buf[0..written]); +} + +test "encode focus lost" { + var buf: [terminal_focus.max_encode_size]u8 = undefined; + var written: usize = 0; + const result = encode(.lost, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[O", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(.gained, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(terminal_focus.max_encode_size, written); +} + +test "encode with null buffer" { + var written: usize = 0; + const result = encode(.gained, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(terminal_focus.max_encode_size, written); +} diff --git a/vendor/libghostty-vt/src/terminal/c/formatter.zig b/vendor/libghostty-vt/src/terminal/c/formatter.zig new file mode 100644 index 00000000..11717bc2 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/formatter.zig @@ -0,0 +1,417 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const formatterpkg = @import("../formatter.zig"); +const Result = @import("result.zig").Result; + +/// Wrapper around formatter that tracks the allocator for C API usage. +const FormatterWrapper = struct { + kind: Kind, + alloc: std.mem.Allocator, + + const Kind = union(enum) { + terminal: formatterpkg.TerminalFormatter, + }; +}; + +/// C: GhosttyFormatter +pub const Formatter = ?*FormatterWrapper; + +/// C: GhosttyFormatterFormat +pub const Format = formatterpkg.Format; + +/// C: GhosttyFormatterScreenOptions +pub const ScreenOptions = extern struct { + /// C: GhosttyFormatterScreenExtra + pub const Extra = extern struct { + size: usize = @sizeOf(Extra), + cursor: bool, + style: bool, + hyperlink: bool, + protection: bool, + kitty_keyboard: bool, + charsets: bool, + + comptime { + for (std.meta.fieldNames(formatterpkg.ScreenFormatter.Extra)) |name| { + if (!@hasField(Extra, name)) + @compileError("ScreenOptions.Extra missing field: " ++ name); + } + } + + fn toZig(self: Extra) formatterpkg.ScreenFormatter.Extra { + return .{ + .cursor = self.cursor, + .style = self.style, + .hyperlink = self.hyperlink, + .protection = self.protection, + .kitty_keyboard = self.kitty_keyboard, + .charsets = self.charsets, + }; + } + }; +}; + +/// C: GhosttyFormatterTerminalOptions +pub const TerminalOptions = extern struct { + size: usize = @sizeOf(TerminalOptions), + emit: Format, + unwrap: bool, + trim: bool, + extra: Extra, + + /// C: GhosttyFormatterTerminalExtra + pub const Extra = extern struct { + size: usize = @sizeOf(Extra), + palette: bool, + modes: bool, + scrolling_region: bool, + tabstops: bool, + pwd: bool, + keyboard: bool, + screen: ScreenOptions.Extra, + + comptime { + for (std.meta.fieldNames(formatterpkg.TerminalFormatter.Extra)) |name| { + if (!@hasField(Extra, name)) + @compileError("TerminalOptions.Extra missing field: " ++ name); + } + } + + fn toZig(self: Extra) formatterpkg.TerminalFormatter.Extra { + return .{ + .palette = self.palette, + .modes = self.modes, + .scrolling_region = self.scrolling_region, + .tabstops = self.tabstops, + .pwd = self.pwd, + .keyboard = self.keyboard, + .screen = self.screen.toZig(), + }; + } + }; +}; + +pub fn terminal_new( + alloc_: ?*const CAllocator, + result: *Formatter, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) callconv(lib.calling_conv) Result { + result.* = terminal_new_( + alloc_, + terminal_, + opts, + ) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn terminal_new_( + alloc_: ?*const CAllocator, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) error{ + InvalidValue, + OutOfMemory, +}!*FormatterWrapper { + const t: *ZigTerminal = (terminal_ orelse return error.InvalidValue).terminal; + + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(FormatterWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.extra = opts.extra.toZig(); + + ptr.* = .{ + .kind = .{ .terminal = formatter }, + .alloc = alloc, + }; + + return ptr; +} + +pub fn format_buf( + formatter_: Formatter, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + + var writer: std.Io.Writer = .fixed(if (out_) |out| + out[0..out_len] + else + &.{}); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&writer) catch |err| switch (err) { + error.WriteFailed => { + // On write failed we always report how much + // space we actually needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + t.format(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }, + } + + out_written.* = writer.end; + return .success; +} + +pub fn format_alloc( + formatter_: Formatter, + alloc_: ?*const CAllocator, + out_ptr: *?[*]u8, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&aw.writer) catch return .out_of_memory, + } + + const buf = aw.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = buf.ptr; + out_len.* = buf.len; + return .success; +} + +pub fn free(formatter_: Formatter) callconv(lib.calling_conv) void { + const wrapper = formatter_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +test "terminal_new/free" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + try testing.expect(f != null); + free(f); +} + +test "terminal_new invalid_value on null terminal" { + var f: Formatter = null; + try testing.expectEqual(Result.invalid_value, terminal_new( + &lib.alloc.test_allocator, + &f, + null, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + try testing.expect(f == null); +} + +test "free null" { + free(null); +} + +test "format plain" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "format reflects terminal changes" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); + + // Write more data and re-format + terminal_c.vt_write(t, "\r\nWorld", 7); + + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello\nWorld", buf[0..written]); +} + +test "format null returns required size" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + // Pass null buffer to query required size + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf(f, null, 0, &required)); + try testing.expect(required > 0); + + // Now allocate and format + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqual(required, written); +} + +test "format buffer too small" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + // Buffer too small + var buf: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf(f, &buf, buf.len, &written)); + // written contains the required size + try testing.expectEqual(@as(usize, 5), written); +} + +test "format null formatter" { + var written: usize = 0; + try testing.expectEqual(Result.invalid_value, format_buf(null, null, 0, &written)); +} + +test "format vt" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Test", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .{ .palette = true, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = true, .hyperlink = true, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); +} + +test "format html" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Html", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .html, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Html") != null); +} diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig new file mode 100644 index 00000000..d029c595 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig @@ -0,0 +1,156 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const PageList = @import("../PageList.zig"); +const size = @import("../size.zig"); +const stylepkg = @import("../style.zig"); +const cell_c = @import("cell.zig"); +const row_c = @import("row.zig"); +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyGridRef +/// +/// A sized struct that holds a reference to a position in the terminal grid. +/// The ref points to a specific cell position within the terminal's +/// internal page structure. +pub const CGridRef = extern struct { + size: usize = @sizeOf(CGridRef), + node: ?*PageList.List.Node = null, + x: size.CellCountInt = 0, + y: size.CellCountInt = 0, + + pub fn fromPin(pin: PageList.Pin) CGridRef { + return .{ + .node = pin.node, + .x = pin.x, + .y = pin.y, + }; + } + + fn toPin(self: CGridRef) ?PageList.Pin { + return .{ + .node = self.node orelse return null, + .x = self.x, + .y = self.y, + }; + } +}; + +pub fn grid_ref_cell( + ref: *const CGridRef, + out: ?*cell_c.CCell, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().cell.*); + return .success; +} + +pub fn grid_ref_row( + ref: *const CGridRef, + out: ?*row_c.CRow, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().row.*); + return .success; +} + +pub fn grid_ref_graphemes( + ref: *const CGridRef, + out_buf: ?[*]u32, + buf_len: usize, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + const cell = p.rowAndCell().cell; + + if (!cell.hasText()) { + out_len.* = 0; + return .success; + } + + const cp = cell.codepoint(); + const extra = if (cell.hasGrapheme()) p.grapheme(cell) else null; + const total = 1 + if (extra) |e| e.len else 0; + + if (out_buf == null or buf_len < total) { + out_len.* = total; + return .out_of_space; + } + + const buf = out_buf.?[0..buf_len]; + buf[0] = cp; + if (extra) |e| for (e, 1..) |c, i| { + buf[i] = c; + }; + + out_len.* = total; + return .success; +} + +pub fn grid_ref_style( + ref: *const CGridRef, + out: ?*style_c.Style, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| { + const cell = p.rowAndCell().cell; + if (cell.style_id == stylepkg.default_id) { + o.* = .fromStyle(.{}); + } else { + o.* = .fromStyle(p.node.data.styles.get( + p.node.data.memory, + cell.style_id, + ).*); + } + } + return .success; +} + +test "grid_ref_cell null node" { + const ref = CGridRef{}; + var out: cell_c.CCell = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, &out)); +} + +test "grid_ref_row null node" { + const ref = CGridRef{}; + var out: row_c.CRow = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, &out)); +} + +test "grid_ref_cell null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, null)); +} + +test "grid_ref_row null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, null)); +} + +test "grid_ref_graphemes null node" { + const ref = CGridRef{}; + var len: usize = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_graphemes null buf returns out_of_space" { + const ref = CGridRef{}; + var len: usize = undefined; + // With null node this returns invalid_value before checking the buffer, + // so we can only test null node here. Full buffer tests require a real page. + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_style null node" { + const ref = CGridRef{}; + var out: style_c.Style = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, &out)); +} + +test "grid_ref_style null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/key_encode.zig b/vendor/libghostty-vt/src/terminal/c/key_encode.zig new file mode 100644 index 00000000..15fa74dd --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/key_encode.zig @@ -0,0 +1,356 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const key_encode = @import("../../input/key_encode.zig"); +const key_event = @import("key_event.zig"); +const KittyFlags = @import("../../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; +const Result = @import("result.zig").Result; +const KeyEvent = @import("key_event.zig").Event; +const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); + +const log = std.log.scoped(.key_encode); + +/// Wrapper around key encoding options that tracks the allocator for C API usage. +const KeyEncoderWrapper = struct { + opts: key_encode.Options, + alloc: Allocator, +}; + +/// C: GhosttyKeyEncoder +pub const Encoder = ?*KeyEncoderWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(KeyEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{}, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +/// C: GhosttyKeyEncoderOption +pub const Option = enum(c_int) { + cursor_key_application = 0, + keypad_key_application = 1, + ignore_keypad_with_numlock = 2, + alt_esc_prefix = 3, + modify_other_keys_state_2 = 4, + kitty_flags = 5, + macos_option_as_alt = 6, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .cursor_key_application, + .keypad_key_application, + .ignore_keypad_with_numlock, + .alt_esc_prefix, + .modify_other_keys_state_2, + => bool, + .kitty_flags => u8, + .macos_option_as_alt => OptionAsAlt, + }; + } +}; + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const opts = &encoder_.?.opts; + switch (option) { + .cursor_key_application => opts.cursor_key_application = value.*, + .keypad_key_application => opts.keypad_key_application = value.*, + .ignore_keypad_with_numlock => opts.ignore_keypad_with_numlock = value.*, + .alt_esc_prefix => opts.alt_esc_prefix = value.*, + .modify_other_keys_state_2 => opts.modify_other_keys_state_2 = value.*, + .kitty_flags => opts.kitty_flags = flags: { + const bits: u5 = @truncate(value.*); + break :flags @bitCast(bits); + }, + .macos_option_as_alt => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(OptionAsAlt, @intFromEnum(value.*)) catch { + log.warn("setopt invalid OptionAsAlt value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + opts.macos_option_as_alt = value.*; + }, + } +} + +pub fn setopt_from_terminal( + encoder_: Encoder, + terminal_: Terminal, +) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; + wrapper.opts = .fromTerminal(t); +} + +pub fn encode( + encoder_: Encoder, + event_: KeyEvent, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + // Attempt to write to this buffer + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + key_encode.encode( + &writer, + event_.?.event, + encoder_.?.opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // If we don't have space, use a discarding writer to count + // how much space we would have needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + key_encode.encode( + &discarding.writer, + event_.?.event, + encoder_.?.opts, + ) catch unreachable; + + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "alloc" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt bool" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting bool options + const val_true: bool = true; + setopt(e, .cursor_key_application, &val_true); + try testing.expect(e.?.opts.cursor_key_application); + + const val_false: bool = false; + setopt(e, .cursor_key_application, &val_false); + try testing.expect(!e.?.opts.cursor_key_application); + + setopt(e, .keypad_key_application, &val_true); + try testing.expect(e.?.opts.keypad_key_application); +} + +test "setopt kitty flags" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting kitty flags + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(e, .kitty_flags, &flags_int); + try testing.expect(e.?.opts.kitty_flags.disambiguate); + try testing.expect(e.?.opts.kitty_flags.report_events); + try testing.expect(!e.?.opts.kitty_flags.report_alternates); +} + +test "setopt macos option as alt" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting option as alt + const opt_left: OptionAsAlt = .left; + setopt(e, .macos_option_as_alt, &opt_left); + try testing.expectEqual(OptionAsAlt.left, e.?.opts.macos_option_as_alt); + + const opt_true: OptionAsAlt = .true; + setopt(e, .macos_option_as_alt, &opt_true); + try testing.expectEqual(OptionAsAlt.true, e.?.opts.macos_option_as_alt); +} + +test "setopt_from_terminal" { + const testing = std.testing; + const terminal_c = @import("terminal.zig"); + + // Create encoder + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Create terminal + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Apply terminal state to encoder + setopt_from_terminal(e, t); + + // Options should reflect defaults from a fresh terminal + try testing.expect(!e.?.opts.cursor_key_application); + try testing.expect(e.?.opts.alt_esc_prefix); + try testing.expectEqual(KittyFlags.disabled, e.?.opts.kitty_flags); + try testing.expectEqual(OptionAsAlt.false, e.?.opts.macos_option_as_alt); +} + +test "setopt_from_terminal null" { + // Both null should be no-ops + setopt_from_terminal(null, null); + + const testing = std.testing; + + // Encoder null with valid terminal + const terminal_c = @import("terminal.zig"); + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + setopt_from_terminal(null, t); + + // Valid encoder with null terminal + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + setopt_from_terminal(e, null); +} + +test "encode: kitty ctrl release with ctrl mod set" { + const testing = std.testing; + + // Create encoder + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + // Set kitty flags with all features enabled + { + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(encoder, .kitty_flags, &flags_int); + } + + // Create key event + var event: key_event.Event = undefined; + try testing.expectEqual(Result.success, key_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer key_event.free(event); + + // Set event properties: release action, ctrl key, ctrl modifier + key_event.set_action(event, .release); + key_event.set_key(event, .control_left); + key_event.set_mods(event, .{ .ctrl = true }); + + // Encode null should give us the length required + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode( + encoder, + event, + null, + 0, + &required, + )); + + // Encode the key event + var buf: [128]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + + // Expected: ESC[57442;5:3u (ctrl key code with mods and release event) + const actual = buf[0..written]; + try testing.expectEqualStrings("\x1b[57442;5:3u", actual); +} diff --git a/vendor/libghostty-vt/src/terminal/c/key_event.zig b/vendor/libghostty-vt/src/terminal/c/key_event.zig new file mode 100644 index 00000000..1feac9ac --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/key_event.zig @@ -0,0 +1,268 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const key = @import("../../input/key.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.key_event); + +/// Wrapper around KeyEvent that tracks the allocator for C API usage. +/// The UTF-8 text is not owned by this wrapper - the caller is responsible +/// for ensuring the lifetime of any UTF-8 text set via set_utf8. +const KeyEventWrapper = struct { + event: key.KeyEvent = .{}, + alloc: Allocator, +}; + +/// C: GhosttyKeyEvent +pub const Event = ?*KeyEventWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(KeyEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(lib.calling_conv) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: key.Action) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + + const event: *key.KeyEvent = &event_.?.event; + event.action = action; +} + +pub fn get_action(event_: Event) callconv(lib.calling_conv) key.Action { + const event: *key.KeyEvent = &event_.?.event; + return event.action; +} + +pub fn set_key(event_: Event, k: key.Key) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Key, @intFromEnum(k)) catch { + log.warn("set_key invalid key value={d}", .{@intFromEnum(k)}); + return; + }; + } + + const event: *key.KeyEvent = &event_.?.event; + event.key = k; +} + +pub fn get_key(event_: Event) callconv(lib.calling_conv) key.Key { + const event: *key.KeyEvent = &event_.?.event; + return event.key; +} + +pub fn set_mods(event_: Event, mods: key.Mods) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(lib.calling_conv) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.mods; +} + +pub fn set_consumed_mods(event_: Event, consumed_mods: key.Mods) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.consumed_mods = consumed_mods; +} + +pub fn get_consumed_mods(event_: Event) callconv(lib.calling_conv) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.consumed_mods; +} + +pub fn set_composing(event_: Event, composing: bool) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.composing = composing; +} + +pub fn get_composing(event_: Event) callconv(lib.calling_conv) bool { + const event: *key.KeyEvent = &event_.?.event; + return event.composing; +} + +pub fn set_utf8(event_: Event, utf8: ?[*]const u8, len: usize) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.utf8 = if (utf8) |ptr| ptr[0..len] else ""; +} + +pub fn get_utf8(event_: Event, len: ?*usize) callconv(lib.calling_conv) ?[*]const u8 { + const event: *key.KeyEvent = &event_.?.event; + if (len) |l| l.* = event.utf8.len; + return if (event.utf8.len == 0) null else event.utf8.ptr; +} + +pub fn set_unshifted_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.unshifted_codepoint = @truncate(codepoint); +} + +pub fn get_unshifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { + const event: *key.KeyEvent = &event_.?.event; + return event.unshifted_codepoint; +} + +test "alloc" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "set" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Test action + set_action(e, .press); + try testing.expectEqual(key.Action.press, e.?.event.action); + + // Test key + set_key(e, .key_a); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + + // Test mods + const mods: key.Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.mods.ctrl); + + // Test consumed mods + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expect(!e.?.event.consumed_mods.ctrl); + + // Test composing + set_composing(e, true); + try testing.expect(e.?.event.composing); + + // Test UTF-8 + const text = "hello"; + set_utf8(e, text.ptr, text.len); + try testing.expectEqualStrings(text, e.?.event.utf8); + + // Test UTF-8 null + set_utf8(e, null, 0); + try testing.expectEqualStrings("", e.?.event.utf8); + + // Test unshifted codepoint + set_unshifted_codepoint(e, 'a'); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); +} + +test "get" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Set some values + set_action(e, .repeat); + set_key(e, .key_z); + + const mods: key.Mods = .{ .alt = true, .super = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .alt = true }; + set_consumed_mods(e, consumed); + + set_composing(e, true); + + const text = "test"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'z'); + + // Get them back + try testing.expectEqual(key.Action.repeat, get_action(e)); + try testing.expectEqual(key.Key.key_z, get_key(e)); + + const got_mods = get_mods(e); + try testing.expect(got_mods.alt); + try testing.expect(got_mods.super); + + const got_consumed = get_consumed_mods(e); + try testing.expect(got_consumed.alt); + try testing.expect(!got_consumed.super); + + try testing.expect(get_composing(e)); + + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 4), utf8_len); + try testing.expectEqualStrings("test", got_utf8.?[0..utf8_len]); + + try testing.expectEqual(@as(u32, 'z'), get_unshifted_codepoint(e)); +} + +test "complete key event" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Build a complete key event for shift+a + set_action(e, .press); + set_key(e, .key_a); + + const mods: key.Mods = .{ .shift = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + + const text = "A"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'a'); + + // Verify all fields + try testing.expectEqual(key.Action.press, e.?.event.action); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expectEqualStrings("A", e.?.event.utf8); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); + + // Also test the getter + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 1), utf8_len); + try testing.expectEqualStrings("A", got_utf8.?[0..utf8_len]); +} diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig new file mode 100644 index 00000000..17056779 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -0,0 +1,178 @@ +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; + +const buildpkg = @import("build_info.zig"); +pub const allocator = @import("allocator.zig"); +pub const cell = @import("cell.zig"); +pub const color = @import("color.zig"); +pub const focus = @import("focus.zig"); +pub const formatter = @import("formatter.zig"); +pub const grid_ref = @import("grid_ref.zig"); +pub const types = @import("types.zig"); +pub const modes = @import("modes.zig"); +pub const osc = @import("osc.zig"); +pub const render = @import("render.zig"); +pub const key_event = @import("key_event.zig"); +pub const key_encode = @import("key_encode.zig"); +pub const mouse_event = @import("mouse_event.zig"); +pub const mouse_encode = @import("mouse_encode.zig"); +pub const paste = @import("paste.zig"); +pub const row = @import("row.zig"); +pub const sgr = @import("sgr.zig"); +pub const size_report = @import("size_report.zig"); +pub const style = @import("style.zig"); +pub const terminal = @import("terminal.zig"); + +// The full C API, unexported. +pub const build_info = buildpkg.get; + +pub const osc_new = osc.new; +pub const osc_free = osc.free; +pub const osc_reset = osc.reset; +pub const osc_next = osc.next; +pub const osc_end = osc.end; +pub const osc_command_type = osc.commandType; +pub const osc_command_data = osc.commandData; + +pub const color_rgb_get = color.rgb_get; + +pub const focus_encode = focus.encode; + +pub const mode_report_encode = modes.report_encode; + +pub const formatter_terminal_new = formatter.terminal_new; +pub const formatter_format_buf = formatter.format_buf; +pub const formatter_format_alloc = formatter.format_alloc; +pub const formatter_free = formatter.free; + +pub const render_state_new = render.new; +pub const render_state_free = render.free; +pub const render_state_update = render.update; +pub const render_state_get = render.get; +pub const render_state_set = render.set; +pub const render_state_colors_get = render.colors_get; +pub const render_state_row_iterator_new = render.row_iterator_new; +pub const render_state_row_iterator_next = render.row_iterator_next; +pub const render_state_row_get = render.row_get; +pub const render_state_row_set = render.row_set; +pub const render_state_row_iterator_free = render.row_iterator_free; +pub const render_state_row_cells_new = render.row_cells_new; +pub const render_state_row_cells_next = render.row_cells_next; +pub const render_state_row_cells_select = render.row_cells_select; +pub const render_state_row_cells_get = render.row_cells_get; +pub const render_state_row_cells_free = render.row_cells_free; + +pub const sgr_new = sgr.new; +pub const sgr_free = sgr.free; +pub const sgr_reset = sgr.reset; +pub const sgr_set_params = sgr.setParams; +pub const sgr_next = sgr.next; +pub const sgr_unknown_full = sgr.unknown_full; +pub const sgr_unknown_partial = sgr.unknown_partial; +pub const sgr_attribute_tag = sgr.attribute_tag; +pub const sgr_attribute_value = sgr.attribute_value; +pub const wasm_alloc_sgr_attribute = sgr.wasm_alloc_attribute; +pub const wasm_free_sgr_attribute = sgr.wasm_free_attribute; + +pub const key_event_new = key_event.new; +pub const key_event_free = key_event.free; +pub const key_event_set_action = key_event.set_action; +pub const key_event_get_action = key_event.get_action; +pub const key_event_set_key = key_event.set_key; +pub const key_event_get_key = key_event.get_key; +pub const key_event_set_mods = key_event.set_mods; +pub const key_event_get_mods = key_event.get_mods; +pub const key_event_set_consumed_mods = key_event.set_consumed_mods; +pub const key_event_get_consumed_mods = key_event.get_consumed_mods; +pub const key_event_set_composing = key_event.set_composing; +pub const key_event_get_composing = key_event.get_composing; +pub const key_event_set_utf8 = key_event.set_utf8; +pub const key_event_get_utf8 = key_event.get_utf8; +pub const key_event_set_unshifted_codepoint = key_event.set_unshifted_codepoint; +pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; + +pub const key_encoder_new = key_encode.new; +pub const key_encoder_free = key_encode.free; +pub const key_encoder_setopt = key_encode.setopt; +pub const key_encoder_setopt_from_terminal = key_encode.setopt_from_terminal; +pub const key_encoder_encode = key_encode.encode; + +pub const mouse_event_new = mouse_event.new; +pub const mouse_event_free = mouse_event.free; +pub const mouse_event_set_action = mouse_event.set_action; +pub const mouse_event_get_action = mouse_event.get_action; +pub const mouse_event_set_button = mouse_event.set_button; +pub const mouse_event_clear_button = mouse_event.clear_button; +pub const mouse_event_get_button = mouse_event.get_button; +pub const mouse_event_set_mods = mouse_event.set_mods; +pub const mouse_event_get_mods = mouse_event.get_mods; +pub const mouse_event_set_position = mouse_event.set_position; +pub const mouse_event_get_position = mouse_event.get_position; + +pub const mouse_encoder_new = mouse_encode.new; +pub const mouse_encoder_free = mouse_encode.free; +pub const mouse_encoder_setopt = mouse_encode.setopt; +pub const mouse_encoder_setopt_from_terminal = mouse_encode.setopt_from_terminal; +pub const mouse_encoder_reset = mouse_encode.reset; +pub const mouse_encoder_encode = mouse_encode.encode; + +pub const paste_is_safe = paste.is_safe; +pub const paste_encode = paste.encode; + +pub const alloc_alloc = allocator.alloc; +pub const alloc_free = allocator.free; + +pub const size_report_encode = size_report.encode; + +pub const cell_get = cell.get; + +pub const row_get = row.get; + +pub const style_default = style.default_style; +pub const style_is_default = style.style_is_default; + +pub const terminal_new = terminal.new; +pub const terminal_free = terminal.free; +pub const terminal_reset = terminal.reset; +pub const terminal_resize = terminal.resize; +pub const terminal_set = terminal.set; +pub const terminal_vt_write = terminal.vt_write; +pub const terminal_scroll_viewport = terminal.scroll_viewport; +pub const terminal_mode_get = terminal.mode_get; +pub const terminal_mode_set = terminal.mode_set; +pub const terminal_get = terminal.get; +pub const terminal_grid_ref = terminal.grid_ref; + +pub const type_json = types.get_json; + +pub const grid_ref_cell = grid_ref.grid_ref_cell; +pub const grid_ref_row = grid_ref.grid_ref_row; +pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; +pub const grid_ref_style = grid_ref.grid_ref_style; + +test { + _ = allocator; + _ = buildpkg; + _ = cell; + _ = color; + _ = grid_ref; + _ = row; + _ = focus; + _ = formatter; + _ = modes; + _ = osc; + _ = render; + _ = key_event; + _ = key_encode; + _ = mouse_event; + _ = mouse_encode; + _ = paste; + _ = sgr; + _ = size_report; + _ = style; + _ = terminal; + _ = types; + + // We want to make sure we run the tests for the C allocator interface. + _ = @import("../../lib/allocator.zig"); +} diff --git a/vendor/libghostty-vt/src/terminal/c/modes.zig b/vendor/libghostty-vt/src/terminal/c/modes.zig new file mode 100644 index 00000000..449ce63e --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/modes.zig @@ -0,0 +1,104 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const modes = @import("../modes.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyModeReportState +pub const ReportState = enum(c_int) { + _, + + fn toZig(self: ReportState) ?modes.Report.State { + return std.meta.intToEnum( + modes.Report.State, + @intFromEnum(self), + ) catch null; + } +}; + +pub fn report_encode( + tag: modes.ModeTag.Backing, + state: ReportState, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const mode_tag: modes.ModeTag = @bitCast(tag); + const report: modes.Report = .{ + .tag = mode_tag, + .state = state.toZig() orelse return .invalid_value, + }; + + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + report.encode(&writer) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + report.encode(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode DEC mode set" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?1;1$y", buf[0..written]); +} + +test "encode DEC mode reset" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(2), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?1;2$y", buf[0..written]); +} + +test "encode ANSI mode" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[4;1$y", buf[0..written]); +} + +test "encode not recognized" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); + const result = report_encode(tag, @enumFromInt(0), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?9999;0$y", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 1); +} + +test "encode with invalid state" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(99), &buf, buf.len, &written); + try std.testing.expectEqual(.invalid_value, result); +} + +test "encode with null buffer" { + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 0); +} diff --git a/vendor/libghostty-vt/src/terminal/c/mouse_encode.zig b/vendor/libghostty-vt/src/terminal/c/mouse_encode.zig new file mode 100644 index 00000000..331dd606 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/mouse_encode.zig @@ -0,0 +1,530 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const input_mouse_encode = @import("../../input/mouse_encode.zig"); +const renderer_size = @import("../../renderer/size.zig"); +const point = @import("../point.zig"); +const terminal_mouse = @import("../mouse.zig"); +const mouse_event = @import("mouse_event.zig"); +const Result = @import("result.zig").Result; +const Event = mouse_event.Event; +const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); + +const log = std.log.scoped(.mouse_encode); + +/// Wrapper around mouse encoding options that tracks the allocator for C API usage. +const MouseEncoderWrapper = struct { + opts: input_mouse_encode.Options, + track_last_cell: bool = false, + last_cell: ?point.Coordinate = null, + alloc: Allocator, +}; + +/// C: GhosttyMouseEncoder +pub const Encoder = ?*MouseEncoderWrapper; + +/// C: GhosttyMouseTrackingMode +pub const TrackingMode = terminal_mouse.Event; + +/// C: GhosttyMouseFormat +pub const Format = terminal_mouse.Format; + +/// C: GhosttyMouseEncoderSize +pub const Size = extern struct { + size: usize = @sizeOf(Size), + screen_width: u32, + screen_height: u32, + cell_width: u32, + cell_height: u32, + padding_top: u32, + padding_bottom: u32, + padding_right: u32, + padding_left: u32, + + fn toRenderer(self: Size) ?renderer_size.Size { + if (self.cell_width == 0 or self.cell_height == 0) return null; + return .{ + .screen = .{ + .width = self.screen_width, + .height = self.screen_height, + }, + .cell = .{ + .width = self.cell_width, + .height = self.cell_height, + }, + .padding = .{ + .top = self.padding_top, + .bottom = self.padding_bottom, + .right = self.padding_right, + .left = self.padding_left, + }, + }; + } +}; + +/// C: GhosttyMouseEncoderOption +pub const Option = enum(c_int) { + event = 0, + format = 1, + size = 2, + any_button_pressed = 3, + track_last_cell = 4, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .event => TrackingMode, + .format => Format, + .size => Size, + .any_button_pressed, + .track_last_cell, + => bool, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(MouseEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{ .size = defaultSize() }, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const wrapper = encoder_.?; + switch (option) { + .event => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TrackingMode, @intFromEnum(value.*)) catch { + log.warn("setopt invalid TrackingMode value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.event != value.*) wrapper.last_cell = null; + wrapper.opts.event = value.*; + }, + + .format => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Format, @intFromEnum(value.*)) catch { + log.warn("setopt invalid Format value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.format != value.*) wrapper.last_cell = null; + wrapper.opts.format = value.*; + }, + + .size => { + if (value.size < @sizeOf(Size)) { + log.warn("setopt size struct too small size={d} expected>={d}", .{ + value.size, + @sizeOf(Size), + }); + return; + } + + wrapper.opts.size = value.toRenderer() orelse { + log.warn("setopt invalid size values (cell width and height must be non-zero)", .{}); + return; + }; + wrapper.last_cell = null; + }, + + .any_button_pressed => wrapper.opts.any_button_pressed = value.*, + + .track_last_cell => { + wrapper.track_last_cell = value.*; + if (!value.*) wrapper.last_cell = null; + }, + } +} + +pub fn setopt_from_terminal( + encoder_: Encoder, + terminal_: Terminal, +) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; + wrapper.opts.event = t.flags.mouse_event; + wrapper.opts.format = t.flags.mouse_format; + wrapper.last_cell = null; +} + +pub fn reset(encoder_: Encoder) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + wrapper.last_cell = null; +} + +pub fn encode( + encoder_: Encoder, + event_: Event, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = encoder_ orelse return .invalid_value; + const event = event_ orelse return .invalid_value; + + const prev_last_cell = wrapper.last_cell; + + var opts = wrapper.opts; + opts.last_cell = if (wrapper.track_last_cell) &wrapper.last_cell else null; + + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + input_mouse_encode.encode( + &writer, + event.event, + opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // Failed writes should not mutate motion dedupe state because no + // complete sequence was produced. + wrapper.last_cell = prev_last_cell; + + // Use a discarding writer to count how much space we would have needed. + var count_last_cell = prev_last_cell; + var count_opts = wrapper.opts; + count_opts.last_cell = if (wrapper.track_last_cell) &count_last_cell else null; + + var discarding: std.Io.Writer.Discarding = .init(&.{}); + input_mouse_encode.encode( + &discarding.writer, + event.event, + count_opts, + ) catch unreachable; + + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +fn defaultSize() renderer_size.Size { + return .{ + .screen = .{ .width = 1, .height = 1 }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }; +} + +fn testSize() Size { + return .{ + .size = @sizeOf(Size), + .screen_width = 1_000, + .screen_height = 1_000, + .cell_width = 1, + .cell_height = 1, + .padding_top = 0, + .padding_bottom = 0, + .padding_right = 0, + .padding_left = 0, + }; +} + +test "alloc" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + try testing.expectEqual(TrackingMode.any, e.?.opts.event); + + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + try testing.expectEqual(Format.sgr, e.?.opts.format); + + const size = testSize(); + setopt(e, .size, &size); + try testing.expectEqual(size.screen_width, e.?.opts.size.screen.width); + try testing.expectEqual(size.screen_height, e.?.opts.size.screen.height); + + const any_button_pressed = true; + setopt(e, .any_button_pressed, &any_button_pressed); + try testing.expect(e.?.opts.any_button_pressed); + + const track_last_cell = true; + setopt(e, .track_last_cell, &track_last_cell); + try testing.expect(e.?.track_last_cell); +} + +test "setopt_from_terminal" { + const terminal_c = @import("terminal.zig"); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + + setopt_from_terminal(e, t); + try testing.expectEqual(TrackingMode.none, e.?.opts.event); + try testing.expectEqual(Format.x10, e.?.opts.format); +} + +test "setopt_from_terminal null" { + setopt_from_terminal(null, null); + + const terminal_c = @import("terminal.zig"); + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + setopt_from_terminal(null, t); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + setopt_from_terminal(e, null); +} + +test "encode: sgr press left" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .press); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 0, .y = 0 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expectEqual(@as(usize, 9), required); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + try testing.expectEqualStrings("\x1b[<0;1;1M", buf[0..written]); +} + +test "encode: motion dedupe and reset" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(@as(usize, 0), written); + } + + reset(encoder); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } +} + +test "encode: querying required size doesn't update dedupe state" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expect(required > 0); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); +} diff --git a/vendor/libghostty-vt/src/terminal/c/mouse_event.zig b/vendor/libghostty-vt/src/terminal/c/mouse_event.zig new file mode 100644 index 00000000..b3c19dd3 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/mouse_event.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const key = @import("../../input/key.zig"); +const mouse = @import("../../input/mouse.zig"); +const mouse_encode = @import("../../input/mouse_encode.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.mouse_event); + +/// Wrapper around mouse event that tracks the allocator for C API usage. +const MouseEventWrapper = struct { + event: mouse_encode.Event = .{}, + alloc: Allocator, +}; + +/// C: GhosttyMouseEvent +pub const Event = ?*MouseEventWrapper; + +/// C: GhosttyMouseAction +pub const Action = mouse.Action; + +/// C: GhosttyMouseButton +pub const Button = mouse.Button; + +/// C: GhosttyMousePosition +pub const Position = mouse_encode.Event.Pos; + +/// C: GhosttyMods +pub const Mods = key.Mods; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(MouseEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(lib.calling_conv) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: Action) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + + event_.?.event.action = action; +} + +pub fn get_action(event_: Event) callconv(lib.calling_conv) Action { + return event_.?.event.action; +} + +pub fn set_button(event_: Event, button: Button) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Button, @intFromEnum(button)) catch { + log.warn("set_button invalid button value={d}", .{@intFromEnum(button)}); + return; + }; + } + + event_.?.event.button = button; +} + +pub fn clear_button(event_: Event) callconv(lib.calling_conv) void { + event_.?.event.button = null; +} + +pub fn get_button(event_: Event, out: ?*Button) callconv(lib.calling_conv) bool { + if (event_.?.event.button) |button| { + if (out) |ptr| ptr.* = button; + return true; + } + + return false; +} + +pub fn set_mods(event_: Event, mods: Mods) callconv(lib.calling_conv) void { + event_.?.event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(lib.calling_conv) Mods { + return event_.?.event.mods; +} + +pub fn set_position(event_: Event, pos: Position) callconv(lib.calling_conv) void { + event_.?.event.pos = pos; +} + +pub fn get_position(event_: Event) callconv(lib.calling_conv) Position { + return event_.?.event.pos; +} + +test "alloc" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "free null" { + free(null); +} + +test "set/get" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Action + set_action(e, .motion); + try testing.expectEqual(Action.motion, get_action(e)); + + // Button + set_button(e, .left); + var button: Button = .unknown; + try testing.expect(get_button(e, &button)); + try testing.expectEqual(Button.left, button); + try testing.expect(get_button(e, null)); + + clear_button(e); + try testing.expect(!get_button(e, &button)); + + // Mods + const mods: Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + const got_mods = get_mods(e); + try testing.expect(got_mods.shift); + try testing.expect(got_mods.ctrl); + try testing.expect(!got_mods.alt); + + // Position + set_position(e, .{ .x = 12.5, .y = -4.0 }); + const pos = get_position(e); + try testing.expectEqual(@as(f32, 12.5), pos.x); + try testing.expectEqual(@as(f32, -4.0), pos.y); +} diff --git a/vendor/libghostty-vt/src/terminal/c/osc.zig b/vendor/libghostty-vt/src/terminal/c/osc.zig new file mode 100644 index 00000000..7ba32300 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/osc.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const osc = @import("../osc.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.osc); + +/// C: GhosttyOscParser +pub const Parser = ?*osc.Parser; + +/// C: GhosttyOscCommand +pub const Command = ?*osc.Command; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Parser, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(osc.Parser) catch + return .out_of_memory; + ptr.* = .init(alloc); + result.* = ptr; + return .success; +} + +pub fn free(parser_: Parser) callconv(lib.calling_conv) void { + // C-built parsers always have an associated allocator. + const parser = parser_ orelse return; + const alloc = parser.alloc.?; + parser.deinit(); + alloc.destroy(parser); +} + +pub fn reset(parser_: Parser) callconv(lib.calling_conv) void { + parser_.?.reset(); +} + +pub fn next(parser_: Parser, byte: u8) callconv(lib.calling_conv) void { + parser_.?.next(byte); +} + +pub fn end(parser_: Parser, terminator: u8) callconv(lib.calling_conv) Command { + return parser_.?.end(terminator); +} + +pub fn commandType(command_: Command) callconv(lib.calling_conv) osc.Command.Key { + const command = command_ orelse return .invalid; + return command.*; +} + +/// C: GhosttyOscCommandData +pub const CommandData = enum(c_int) { + invalid = 0, + change_window_title_str = 1, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CommandData) type { + return switch (self) { + .invalid => void, + .change_window_title_str => [*:0]const u8, + }; + } +}; + +pub fn commandData( + command_: Command, + data: CommandData, + out: ?*anyopaque, +) callconv(lib.calling_conv) bool { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CommandData, @intFromEnum(data)) catch { + log.warn("commandData invalid data value={d}", .{@intFromEnum(data)}); + return false; + }; + } + + return switch (data) { + .invalid => false, + inline else => |comptime_data| commandDataTyped( + command_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn commandDataTyped( + command_: Command, + comptime data: CommandData, + out: *data.OutType(), +) bool { + const command = command_.?; + switch (data) { + .invalid => return false, + .change_window_title_str => switch (command.*) { + .change_window_title => |v| out.* = v.ptr, + else => return false, + }, + } + + return true; +} + +test "alloc" { + const testing = std.testing; + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &p, + )); + free(p); +} + +test "command type null" { + const testing = std.testing; + try testing.expectEqual(.invalid, commandType(null)); +} + +test "change window title" { + const testing = std.testing; + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &p, + )); + defer free(p); + + // Parse it + next(p, '0'); + next(p, ';'); + next(p, 'a'); + const cmd = end(p, 0); + try testing.expectEqual(.change_window_title, commandType(cmd)); + + // Extract the title + var title: [*:0]const u8 = undefined; + try testing.expect(commandData(cmd, .change_window_title_str, @ptrCast(&title))); + try testing.expectEqualStrings("a", std.mem.span(title)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/paste.zig b/vendor/libghostty-vt/src/terminal/c/paste.zig new file mode 100644 index 00000000..bce6a565 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/paste.zig @@ -0,0 +1,129 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const paste = @import("../../input/paste.zig"); +const Result = @import("result.zig").Result; + +pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { + const slice: []const u8 = if (data) |v| v[0..len] else &.{}; + return paste.isSafe(slice); +} + +pub fn encode( + data: ?[*]u8, + data_len: usize, + bracketed: bool, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const slice: []u8 = if (data) |v| v[0..data_len] else &.{}; + const result = paste.encode(slice, .{ .bracketed = bracketed }); + + const total = result[0].len + result[1].len + result[2].len; + out_written.* = total; + + const out: []u8 = if (out_) |o| o[0..out_len] else &.{}; + if (out.len < total) return .out_of_space; + + var offset: usize = 0; + for (result) |segment| { + @memcpy(out[offset..][0..segment.len], segment); + offset += segment.len; + } + + return .success; +} + +test "encode bracketed" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hello"); + defer testing.allocator.free(input); + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(input.ptr, input.len, true, &buf, buf.len, &written); + try testing.expectEqual(.success, result); + try testing.expectEqualStrings("\x1b[200~hello\x1b[201~", buf[0..written]); +} + +test "encode unbracketed no newlines" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hello"); + defer testing.allocator.free(input); + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(input.ptr, input.len, false, &buf, buf.len, &written); + try testing.expectEqual(.success, result); + try testing.expectEqualStrings("hello", buf[0..written]); +} + +test "encode unbracketed newlines" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hello\nworld"); + defer testing.allocator.free(input); + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(input.ptr, input.len, false, &buf, buf.len, &written); + try testing.expectEqual(.success, result); + try testing.expectEqualStrings("hello\rworld", buf[0..written]); +} + +test "encode strip unsafe bytes" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hel\x1blo\x00world"); + defer testing.allocator.free(input); + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(input.ptr, input.len, true, &buf, buf.len, &written); + try testing.expectEqual(.success, result); + try testing.expectEqualStrings("\x1b[200~hel lo world\x1b[201~", buf[0..written]); +} + +test "encode with insufficient buffer" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hello"); + defer testing.allocator.free(input); + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(input.ptr, input.len, true, &buf, buf.len, &written); + try testing.expectEqual(.out_of_space, result); + try testing.expectEqual(17, written); +} + +test "encode with null buffer" { + const testing = std.testing; + const input = try testing.allocator.dupe(u8, "hello"); + defer testing.allocator.free(input); + var written: usize = 0; + const result = encode(input.ptr, input.len, true, null, 0, &written); + try testing.expectEqual(.out_of_space, result); + try testing.expectEqual(17, written); +} + +test "is_safe with safe data" { + const testing = std.testing; + const safe = "hello world"; + try testing.expect(is_safe(safe.ptr, safe.len)); +} + +test "is_safe with newline" { + const testing = std.testing; + const unsafe = "hello\nworld"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with bracketed paste end" { + const testing = std.testing; + const unsafe = "hello\x1b[201~world"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with empty data" { + const testing = std.testing; + const empty = ""; + try testing.expect(is_safe(empty.ptr, 0)); +} + +test "is_safe with null empty data" { + const testing = std.testing; + try testing.expect(is_safe(null, 0)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig new file mode 100644 index 00000000..b95bedd5 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/render.zig @@ -0,0 +1,1382 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const colorpkg = @import("../color.zig"); +const cursorpkg = @import("../cursor.zig"); +const page = @import("../page.zig"); +const size = @import("../size.zig"); +const Style = @import("../style.zig").Style; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const renderpkg = @import("../render.zig"); +const Result = @import("result.zig").Result; +const row = @import("row.zig"); +const style_c = @import("style.zig"); + +const log = std.log.scoped(.render_state_c); + +const RenderStateWrapper = struct { + alloc: std.mem.Allocator, + state: renderpkg.RenderState = .empty, +}; + +const RowIteratorWrapper = struct { + alloc: std.mem.Allocator, + + /// The current index (also y value) into the row list. + y: ?size.CellCountInt, + + /// These are the raw pointers into the render state data. + raws: []const page.Row, + cells: []const std.MultiArrayList(renderpkg.RenderState.Cell), + dirty: []bool, + + /// The color palette from the render state, needed to resolve + /// palette-indexed background colors on cells. + palette: *const colorpkg.Palette, +}; + +const RowCellsWrapper = struct { + alloc: std.mem.Allocator, + x: ?size.CellCountInt, + raws: []const page.Cell, + graphemes: []const []const u21, + styles: []const Style, + + /// The color palette, needed to resolve palette-indexed background colors. + palette: *const colorpkg.Palette, +}; + +/// C: GhosttyRenderState +pub const RenderState = ?*RenderStateWrapper; + +/// C: GhosttyRenderStateRowIterator +pub const RowIterator = ?*RowIteratorWrapper; + +/// C: GhosttyRenderStateRowCells +pub const RowCells = ?*RowCellsWrapper; + +/// C: GhosttyRenderStateDirty +pub const Dirty = renderpkg.RenderState.Dirty; + +/// C: GhosttyRenderStateCursorVisualStyle +pub const CursorVisualStyle = enum(c_int) { + bar = 0, + block = 1, + underline = 2, + block_hollow = 3, + + pub fn fromCursorStyle(s: cursorpkg.Style) CursorVisualStyle { + return switch (s) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + }; + } +}; + +/// C: GhosttyRenderStateData +pub const Data = enum(c_int) { + invalid = 0, + cols = 1, + rows = 2, + dirty = 3, + row_iterator = 4, + color_background = 5, + color_foreground = 6, + color_cursor = 7, + color_cursor_has_value = 8, + color_palette = 9, + cursor_visual_style = 10, + cursor_visible = 11, + cursor_blinking = 12, + cursor_password_input = 13, + cursor_viewport_has_value = 14, + cursor_viewport_x = 15, + cursor_viewport_y = 16, + cursor_viewport_wide_tail = 17, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: Data) type { + return switch (self) { + .invalid => void, + .cols, .rows => size.CellCountInt, + .dirty => Dirty, + .row_iterator => RowIterator, + .color_background, .color_foreground, .color_cursor => colorpkg.RGB.C, + .color_cursor_has_value => bool, + .color_palette => colorpkg.PaletteC, + .cursor_visual_style => CursorVisualStyle, + .cursor_visible, .cursor_blinking, .cursor_password_input => bool, + .cursor_viewport_has_value, .cursor_viewport_wide_tail => bool, + .cursor_viewport_x, .cursor_viewport_y => size.CellCountInt, + }; + } +}; + +/// C: GhosttyRenderStateOption +pub const SetOption = enum(c_int) { + dirty = 0, + + /// Input type expected for setting the option. + pub fn InType(comptime self: SetOption) type { + return switch (self) { + .dirty => Dirty, + }; + } +}; + +/// C: GhosttyRenderStateColors +pub const Colors = extern struct { + size: usize = @sizeOf(Colors), + background: colorpkg.RGB.C, + foreground: colorpkg.RGB.C, + cursor: colorpkg.RGB.C, + cursor_has_value: bool, + palette: colorpkg.PaletteC, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *RenderState, +) callconv(lib.calling_conv) Result { + result.* = new_(alloc_) catch |err| { + result.* = null; + return switch (err) { + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_(alloc_: ?*const CAllocator) error{OutOfMemory}!*RenderStateWrapper { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RenderStateWrapper) catch + return error.OutOfMemory; + ptr.* = .{ .alloc = alloc }; + return ptr; +} + +pub fn free(state_: RenderState) callconv(lib.calling_conv) void { + const state = state_ orelse return; + const alloc = state.alloc; + state.state.deinit(alloc); + alloc.destroy(state); +} + +pub fn update( + state_: RenderState, + terminal_: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + + state.state.update(state.alloc, t) catch return .out_of_memory; + return .success; +} + +pub fn get( + state_: RenderState, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Data, @intFromEnum(data)) catch { + log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + state_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + state_: RenderState, + comptime data: Data, + out: *data.OutType(), +) Result { + const state = state_ orelse return .invalid_value; + switch (data) { + .invalid => return .invalid_value, + .cols => out.* = state.state.cols, + .rows => out.* = state.state.rows, + .dirty => out.* = state.state.dirty, + .row_iterator => { + const it = out.* orelse return .invalid_value; + const row_data = state.state.row_data.slice(); + it.* = .{ + .alloc = it.alloc, + .y = null, + .raws = row_data.items(.raw), + .cells = row_data.items(.cells), + .dirty = row_data.items(.dirty), + .palette = &state.state.colors.palette, + }; + }, + .color_background => out.* = state.state.colors.background.cval(), + .color_foreground => out.* = state.state.colors.foreground.cval(), + .color_cursor => { + const cursor = state.state.colors.cursor orelse return .invalid_value; + out.* = cursor.cval(); + }, + .color_cursor_has_value => out.* = state.state.colors.cursor != null, + .color_palette => out.* = colorpkg.paletteCval(&state.state.colors.palette), + .cursor_visual_style => out.* = CursorVisualStyle.fromCursorStyle(state.state.cursor.visual_style), + .cursor_visible => out.* = state.state.cursor.visible, + .cursor_blinking => out.* = state.state.cursor.blinking, + .cursor_password_input => out.* = state.state.cursor.password_input, + .cursor_viewport_has_value => out.* = state.state.cursor.viewport != null, + .cursor_viewport_x => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.x; + }, + .cursor_viewport_y => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.y; + }, + .cursor_viewport_wide_tail => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.wide_tail; + }, + } + + return .success; +} + +pub fn set( + state_: RenderState, + option: SetOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(SetOption, @intFromEnum(option)) catch { + log.warn("render_state_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + state_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn setTyped( + state_: RenderState, + comptime option: SetOption, + value: *const option.InType(), +) Result { + const state = state_ orelse return .invalid_value; + switch (option) { + .dirty => state.state.dirty = value.*, + } + + return .success; +} + +pub fn colors_get( + state_: RenderState, + out_colors_: ?*Colors, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const out_colors = out_colors_ orelse return .invalid_value; + const out_size = out_colors.size; + if (out_size < @sizeOf(usize)) return .invalid_value; + + const colors = state.state.colors; + if (lib.structSizedFieldFits( + Colors, + out_size, + "background", + )) { + out_colors.background = colors.background.cval(); + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "foreground", + )) { + out_colors.foreground = colors.foreground.cval(); + } + + if (colors.cursor) |cursor| { + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor", + )) { + out_colors.cursor = cursor.cval(); + } + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor_has_value", + )) { + out_colors.cursor_has_value = colors.cursor != null; + } + + { + const palette_offset = @offsetOf(Colors, "palette"); + if (out_size > palette_offset) { + const available = out_size - palette_offset; + const max_entries = @min(colors.palette.len, available / @sizeOf(colorpkg.RGB.C)); + for (0..max_entries) |i| { + out_colors.palette[i] = colors.palette[i].cval(); + } + } + } + + return .success; +} + +pub fn row_iterator_new( + alloc_: ?*const CAllocator, + result: *RowIterator, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RowIteratorWrapper) catch { + result.* = null; + return .out_of_memory; + }; + ptr.* = .{ + .alloc = alloc, + .y = undefined, + .raws = undefined, + .cells = undefined, + .dirty = undefined, + .palette = undefined, + }; + result.* = ptr; + return .success; +} + +pub fn row_iterator_free(iterator_: RowIterator) callconv(lib.calling_conv) void { + const iterator = iterator_ orelse return; + const alloc = iterator.alloc; + alloc.destroy(iterator); +} + +pub fn row_iterator_next(iterator_: RowIterator) callconv(lib.calling_conv) bool { + const it = iterator_ orelse return false; + const next_y: size.CellCountInt = if (it.y) |y| y + 1 else 0; + if (next_y >= it.raws.len) return false; + it.y = next_y; + return true; +} + +pub fn row_cells_new( + alloc_: ?*const CAllocator, + result: *RowCells, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RowCellsWrapper) catch { + result.* = null; + return .out_of_memory; + }; + ptr.* = .{ + .alloc = alloc, + .x = undefined, + .raws = undefined, + .graphemes = undefined, + .styles = undefined, + .palette = undefined, + }; + result.* = ptr; + return .success; +} + +pub fn row_cells_next(cells_: RowCells) callconv(lib.calling_conv) bool { + const cells = cells_ orelse return false; + const next_x: size.CellCountInt = if (cells.x) |x| x + 1 else 0; + if (next_x >= cells.raws.len) return false; + cells.x = next_x; + return true; +} + +pub fn row_cells_select(cells_: RowCells, x: size.CellCountInt) callconv(lib.calling_conv) Result { + const cells = cells_ orelse return .invalid_value; + if (x >= cells.raws.len) return .invalid_value; + cells.x = x; + return .success; +} + +pub fn row_cells_free(cells_: RowCells) callconv(lib.calling_conv) void { + const cells = cells_ orelse return; + const alloc = cells.alloc; + alloc.destroy(cells); +} + +/// C: GhosttyRenderStateRowCellsData +pub const RowCellsData = enum(c_int) { + invalid = 0, + raw = 1, + style = 2, + graphemes_len = 3, + graphemes_buf = 4, + bg_color = 5, + fg_color = 6, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowCellsData) type { + return switch (self) { + .invalid => void, + .raw => page.Cell.C, + .style => style_c.Style, + .graphemes_len => u32, + .graphemes_buf => u32, + .bg_color, .fg_color => colorpkg.RGB.C, + }; + } +}; + +pub fn row_cells_get( + cells_: RowCells, + data: RowCellsData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowCellsData, @intFromEnum(data)) catch { + log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowCellsGetTyped( + cells_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn rowCellsGetTyped( + cells_: RowCells, + comptime data: RowCellsData, + out: *data.OutType(), +) Result { + const cells = cells_ orelse return .invalid_value; + const x = cells.x orelse return .invalid_value; + const cell = cells.raws[x]; + switch (data) { + .invalid => return .invalid_value, + .raw => out.* = cell.cval(), + .style => out.* = if (cell.hasStyling()) + style_c.Style.fromStyle(cells.styles[x]) + else + style_c.Style.fromStyle(.{}), + .graphemes_len => { + if (!cell.hasText()) { + out.* = 0; + return .success; + } + const extra = if (cell.hasGrapheme()) cells.graphemes[x] else &[_]u21{}; + out.* = @intCast(1 + extra.len); + }, + .graphemes_buf => { + if (!cell.hasText()) return .success; + const extra = if (cell.hasGrapheme()) cells.graphemes[x] else &[_]u21{}; + const buf: [*]u32 = @ptrCast(out); + buf[0] = cell.codepoint(); + for (extra, 1..) |cp, i| { + buf[i] = cp; + } + }, + .bg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + const bg = s.bg(&cell, cells.palette) orelse return .invalid_value; + out.* = bg.cval(); + }, + .fg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + if (s.fg_color == .none) return .invalid_value; + const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); + out.* = fg.cval(); + }, + } + + return .success; +} + +/// C: GhosttyRenderStateRowData +pub const RowData = enum(c_int) { + invalid = 0, + dirty = 1, + raw = 2, + cells = 3, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowData) type { + return switch (self) { + .invalid => void, + .dirty => bool, + .raw => row.CRow, + .cells => RowCells, + }; + } +}; + +/// C: GhosttyRenderStateRowOption +pub const RowOption = enum(c_int) { + dirty = 0, + + /// Input type expected for setting the option. + pub fn InType(comptime self: RowOption) type { + return switch (self) { + .dirty => bool, + }; + } +}; + +pub fn row_get( + iterator_: RowIterator, + data: RowData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowData, @intFromEnum(data)) catch { + log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowGetTyped( + iterator_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn rowGetTyped( + iterator_: RowIterator, + comptime data: RowData, + out: *data.OutType(), +) Result { + const it = iterator_ orelse return .invalid_value; + const y = it.y orelse return .invalid_value; + switch (data) { + .invalid => return .invalid_value, + .dirty => out.* = it.dirty[y], + .raw => out.* = it.raws[y].cval(), + .cells => { + const cells = out.* orelse return .invalid_value; + const cell_data = it.cells[y].slice(); + cells.* = .{ + .alloc = cells.alloc, + .x = null, + .raws = cell_data.items(.raw), + .graphemes = cell_data.items(.grapheme), + .styles = cell_data.items(.style), + .palette = it.palette, + }; + }, + } + + return .success; +} + +pub fn row_set( + iterator_: RowIterator, + option: RowOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowOption, @intFromEnum(option)) catch { + log.warn("render_state_row_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| rowSetTyped( + iterator_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn rowSetTyped( + iterator_: RowIterator, + comptime option: RowOption, + value: *const option.InType(), +) Result { + const it = iterator_ orelse return .invalid_value; + const y = it.y orelse return .invalid_value; + switch (option) { + .dirty => it.dirty[y] = value.*, + } + + return .success; +} + +test "render: new/free" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + try testing.expect(state != null); + free(state); +} + +test "render: free null" { + free(null); +} + +test "render: update invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, update(null, null)); + try testing.expectEqual(Result.invalid_value, update(state, null)); +} + +test "render: get invalid value" { + var cols: size.CellCountInt = 0; + try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); +} + +test "render: get invalid data" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, get(state, .invalid, null)); +} + +test "render: colors get invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + + try testing.expectEqual(Result.invalid_value, colors_get(null, &colors)); + try testing.expectEqual(Result.invalid_value, colors_get(state, null)); + + colors.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, colors_get(state, &colors)); +} + +test "render: get/set dirty invalid value" { + var dirty: Dirty = .false; + try testing.expectEqual(Result.invalid_value, get(null, .dirty, @ptrCast(&dirty))); + const dirty_full: Dirty = .full; + try testing.expectEqual(Result.invalid_value, set(null, .dirty, @ptrCast(&dirty_full))); +} + +test "render: get/set dirty" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + var dirty: Dirty = undefined; + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.false, dirty); + + const dirty_partial: Dirty = .partial; + try testing.expectEqual(Result.success, set(state, .dirty, @ptrCast(&dirty_partial))); + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.partial, dirty); + + const dirty_full: Dirty = .full; + try testing.expectEqual(Result.success, set(state, .dirty, @ptrCast(&dirty_full))); + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.full, dirty); +} + +test "render: set null value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, set(state, .dirty, null)); +} + +test "render: row iterator get invalid value" { + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.invalid_value, get(null, .row_iterator, @ptrCast(&iterator))); +} + +test "render: row iterator new/free" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expect(iterator != null); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + + const iterator_ptr = iterator.?; + const row_data = state.?.state.row_data.slice(); + + try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y); + try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len); + try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len); + try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len); +} + +test "render: row iterator free null" { + row_iterator_free(null); +} + +test "render: row iterator next null" { + try testing.expect(!row_iterator_next(null)); +} + +test "render: row get null" { + var dirty: bool = undefined; + try testing.expectEqual(Result.invalid_value, row_get(null, .dirty, @ptrCast(&dirty))); +} + +test "render: row get invalid data" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(Result.invalid_value, row_get(iterator, .invalid, null)); +} + +test "render: row set null" { + const dirty = false; + try testing.expectEqual(Result.invalid_value, row_set(null, .dirty, @ptrCast(&dirty))); +} + +test "render: row set before iteration" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + const dirty = false; + try testing.expectEqual(Result.invalid_value, row_set(iterator, .dirty, @ptrCast(&dirty))); +} + +test "render: row get before iteration" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + var dirty: bool = undefined; + try testing.expectEqual(Result.invalid_value, row_get(iterator, .dirty, @ptrCast(&dirty))); +} + +test "render: row get/set dirty" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + // Dirty the first row so the iterator has at least one dirty row to observe. + terminal_c.vt_write(terminal, "hello", 5); + try testing.expectEqual(Result.success, update(state, terminal)); + + // Create an iterator and verify it is dirty. + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + var dirty: bool = undefined; + try testing.expectEqual(Result.success, row_get(it, .dirty, @ptrCast(&dirty))); + try testing.expect(dirty); + + // Clear dirty on this row. + const dirty_false = false; + try testing.expectEqual(Result.success, row_set(it, .dirty, @ptrCast(&dirty_false))); + + // It should not be dirty anymore. + var it2: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it2, + )); + defer row_iterator_free(it2); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it2))); + try testing.expect(row_iterator_next(it2)); + try testing.expectEqual(Result.success, row_get(it2, .dirty, @ptrCast(&dirty))); + try testing.expect(!dirty); +} + +test "render: row iterator next" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + + const rows = state.?.state.rows; + if (rows == 0) { + try testing.expect(!row_iterator_next(iterator)); + return; + } + + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, 0), iterator.?.y); + + var i: size.CellCountInt = 1; + while (i < rows) : (i += 1) { + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, i), iterator.?.y); + } + + try testing.expect(!row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, rows - 1), iterator.?.y); +} + +test "render: update" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + terminal_c.vt_write(terminal, "hello", 5); + try testing.expectEqual(Result.success, update(state, terminal)); + + var cols: size.CellCountInt = 0; + var rows_val: size.CellCountInt = 0; + try testing.expectEqual(Result.success, get(state, .cols, @ptrCast(&cols))); + try testing.expectEqual(Result.success, get(state, .rows, @ptrCast(&rows_val))); + try testing.expectEqual(@as(size.CellCountInt, 80), cols); + try testing.expectEqual(@as(size.CellCountInt, 24), rows_val); +} + +test "render: colors get" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.background.cval(), colors.background); + try testing.expectEqual(state_colors.foreground.cval(), colors.foreground); + + if (state_colors.cursor) |cursor| { + try testing.expect(colors.cursor_has_value); + try testing.expectEqual(cursor.cval(), colors.cursor); + } else { + try testing.expect(!colors.cursor_has_value); + } + + for (state_colors.palette, colors.palette) |expected, actual| { + try testing.expectEqual(expected.cval(), actual); + } +} + +test "render: row cells bg_color no background" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no background color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No background set, should return invalid_value. + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .bg_color, @ptrCast(&bg))); +} + +test "render: row cells bg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background via SGR 48;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells bg_color from content tag" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background and then erase the line. The erased cells + // should carry the background color via the content tag (bg_color_rgb) + // rather than through the style. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30m\x1b[2K", 21); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells fg_color no foreground" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no foreground color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No foreground set, should return invalid_value. + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .fg_color, @ptrCast(&fg))); +} + +test "render: row cells fg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB foreground via SGR 38;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[38;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .fg_color, @ptrCast(&fg))); + try testing.expectEqual(@as(u8, 10), fg.r); + try testing.expectEqual(@as(u8, 20), fg.g); + try testing.expectEqual(@as(u8, 30), fg.b); +} + +test "render: colors get supports truncated sized struct" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + const sentinel: colorpkg.RGB.C = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC }; + for (&colors.palette) |*entry| entry.* = sentinel; + + colors.size = @offsetOf(Colors, "palette") + @sizeOf(colorpkg.RGB.C) * 2; + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.palette[0].cval(), colors.palette[0]); + try testing.expectEqual(state_colors.palette[1].cval(), colors.palette[1]); + try testing.expectEqual(sentinel, colors.palette[2]); +} diff --git a/vendor/libghostty-vt/src/terminal/c/result.zig b/vendor/libghostty-vt/src/terminal/c/result.zig new file mode 100644 index 00000000..32866328 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/result.zig @@ -0,0 +1,8 @@ +/// C: GhosttyResult +pub const Result = enum(c_int) { + success = 0, + out_of_memory = -1, + invalid_value = -2, + out_of_space = -3, + no_value = -4, +}; diff --git a/vendor/libghostty-vt/src/terminal/c/row.zig b/vendor/libghostty-vt/src/terminal/c/row.zig new file mode 100644 index 00000000..d4532706 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/row.zig @@ -0,0 +1,132 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const Row = page.Row; +const Result = @import("result.zig").Result; + +/// C: GhosttyRow +pub const CRow = Row.C; + +/// C: GhosttyRowSemanticPrompt +pub const SemanticPrompt = enum(c_int) { + none = 0, + prompt = 1, + prompt_continuation = 2, +}; + +/// C: GhosttyRowData +pub const RowData = enum(c_int) { + invalid = 0, + + /// Whether this row is soft-wrapped. + /// Output type: bool * + wrap = 1, + + /// Whether this row is a continuation of a soft-wrapped row. + /// Output type: bool * + wrap_continuation = 2, + + /// Whether any cells in this row have grapheme clusters. + /// Output type: bool * + grapheme = 3, + + /// Whether any cells in this row have styling (may have false positives). + /// Output type: bool * + styled = 4, + + /// Whether any cells in this row have hyperlinks (may have false positives). + /// Output type: bool * + hyperlink = 5, + + /// The semantic prompt state of this row. + /// Output type: GhosttyRowSemanticPrompt * + semantic_prompt = 6, + + /// Whether this row contains a Kitty virtual placeholder. + /// Output type: bool * + kitty_virtual_placeholder = 7, + + /// Whether this row is dirty and requires a redraw. + /// Output type: bool * + dirty = 8, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowData) type { + return switch (self) { + .invalid => void, + .wrap, .wrap_continuation, .grapheme, .styled, .hyperlink => bool, + .kitty_virtual_placeholder, .dirty => bool, + .semantic_prompt => SemanticPrompt, + }; + } +}; + +pub fn get( + row_: CRow, + data: RowData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + row_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + row_: CRow, + comptime data: RowData, + out: *data.OutType(), +) Result { + const row: Row = @bitCast(row_); + switch (data) { + .invalid => return .invalid_value, + .wrap => out.* = row.wrap, + .wrap_continuation => out.* = row.wrap_continuation, + .grapheme => out.* = row.grapheme, + .styled => out.* = row.styled, + .hyperlink => out.* = row.hyperlink, + .semantic_prompt => out.* = @enumFromInt(@intFromEnum(row.semantic_prompt)), + .kitty_virtual_placeholder => out.* = row.kitty_virtual_placeholder, + .dirty => out.* = row.dirty, + } + + return .success; +} + +test "get wrap" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.wrap = true; + const row: CRow = @bitCast(zig_row); + var wrap: bool = false; + try testing.expectEqual(Result.success, get(row, .wrap, @ptrCast(&wrap))); + try testing.expect(wrap); +} + +test "get semantic_prompt" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.semantic_prompt = .prompt; + const row: CRow = @bitCast(zig_row); + var sp: SemanticPrompt = .none; + try testing.expectEqual(Result.success, get(row, .semantic_prompt, @ptrCast(&sp))); + try testing.expectEqual(SemanticPrompt.prompt, sp); +} + +test "get dirty" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.dirty = true; + const row: CRow = @bitCast(zig_row); + var dirty: bool = false; + try testing.expectEqual(Result.success, get(row, .dirty, @ptrCast(&dirty))); + try testing.expect(dirty); +} diff --git a/vendor/libghostty-vt/src/terminal/c/sgr.zig b/vendor/libghostty-vt/src/terminal/c/sgr.zig new file mode 100644 index 00000000..b0ef23d0 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/sgr.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const sgr = @import("../sgr.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.sgr); + +/// Wrapper around parser that tracks the allocator for C API usage. +const ParserWrapper = struct { + parser: sgr.Parser, + alloc: Allocator, +}; + +/// C: GhosttySgrParser +pub const Parser = ?*ParserWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Parser, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(ParserWrapper) catch + return .out_of_memory; + ptr.* = .{ + .parser = .empty, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(parser_: Parser) callconv(lib.calling_conv) void { + const wrapper = parser_ orelse return; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.params.len > 0) alloc.free(parser.params); + alloc.destroy(wrapper); +} + +pub fn reset(parser_: Parser) callconv(lib.calling_conv) void { + const wrapper = parser_ orelse return; + const parser: *sgr.Parser = &wrapper.parser; + parser.idx = 0; +} + +pub fn setParams( + parser_: Parser, + params: [*]const u16, + seps_: ?[*]const u8, + len: usize, +) callconv(lib.calling_conv) Result { + const wrapper = parser_ orelse return .invalid_value; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + + // Copy our new parameters + const params_slice = alloc.dupe(u16, params[0..len]) catch + return .out_of_memory; + if (parser.params.len > 0) alloc.free(parser.params); + parser.params = params_slice; + + // If we have separators, set that state too. + parser.params_sep = .initEmpty(); + if (seps_) |seps| { + if (len > @TypeOf(parser.params_sep).bit_length) { + log.warn("ghostty_sgr_set_params: separators length {} exceeds max supported length {}", .{ + len, + @TypeOf(parser.params_sep).bit_length, + }); + return .invalid_value; + } + + for (seps[0..len], 0..) |sep, i| { + if (sep == ':') parser.params_sep.set(i); + } + } + + // Reset our parsing state + parser.idx = 0; + + return .success; +} + +pub fn next( + parser_: Parser, + result: *sgr.Attribute.C, +) callconv(lib.calling_conv) bool { + const wrapper = parser_ orelse return false; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.next()) |attr| { + result.* = attr.cval(); + return true; + } + + return false; +} + +pub fn unknown_full( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(lib.calling_conv) usize { + if (ptr) |p| p.* = unknown.full_ptr; + return unknown.full_len; +} + +pub fn unknown_partial( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(lib.calling_conv) usize { + if (ptr) |p| p.* = unknown.partial_ptr; + return unknown.partial_len; +} + +pub fn attribute_tag( + attr: sgr.Attribute.C, +) callconv(lib.calling_conv) sgr.Attribute.Tag { + return attr.tag; +} + +pub fn attribute_value( + attr: *sgr.Attribute.C, +) callconv(lib.calling_conv) *sgr.Attribute.CValue { + return &attr.value; +} + +pub fn wasm_alloc_attribute() callconv(lib.calling_conv) *sgr.Attribute.C { + const alloc = std.heap.wasm_allocator; + const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory"); + return ptr; +} + +pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(lib.calling_conv) void { + const alloc = std.heap.wasm_allocator; + alloc.destroy(attr); +} + +test "alloc" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &p, + )); + free(p); +} + +test "simple params, no seps" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &p, + )); + defer free(p); + + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Set it twice on purpose to make sure we don't leak. + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Verify we get bold + var attr: sgr.Attribute.C = undefined; + try testing.expect(next(p, &attr)); + try testing.expectEqual(.bold, attr.tag); + + // Nothing else + try testing.expect(!next(p, &attr)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/size_report.zig b/vendor/libghostty-vt/src/terminal/c/size_report.zig new file mode 100644 index 00000000..1c350c8f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/size_report.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const terminal_size_report = @import("../size_report.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttySizeReportStyle +pub const Style = terminal_size_report.Style; + +/// C: GhosttySizeReportSize +pub const Size = terminal_size_report.Size; + +pub fn encode( + style: Style, + size: Size, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + terminal_size_report.encode(&writer, style, size) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + terminal_size_report.encode(&discarding.writer, style, size) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode mode 2048" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.mode_2048, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[48;24;80;432;720t", buf[0..written]); +} + +test "encode csi 14 t" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_14_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1b[4;432;720t", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 1); +} + +test "encode with null buffer" { + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 0); +} diff --git a/vendor/libghostty-vt/src/terminal/c/style.zig b/vendor/libghostty-vt/src/terminal/c/style.zig new file mode 100644 index 00000000..82729fa9 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/style.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const lib = @import("../lib.zig"); +const style = @import("../style.zig"); +const color = @import("../color.zig"); +const sgr = @import("../sgr.zig"); + +/// C: GhosttyStyleColorTag +pub const ColorTag = enum(c_int) { + none = 0, + palette = 1, + rgb = 2, +}; + +/// C: GhosttyStyleColorValue +pub const ColorValue = extern union { + palette: u8, + rgb: color.RGB.C, + _padding: u64, +}; + +/// C: GhosttyStyleColor +pub const Color = extern struct { + tag: ColorTag, + value: ColorValue, + + pub fn fromColor(c: style.Style.Color) Color { + return switch (c) { + .none => .{ + .tag = .none, + .value = .{ ._padding = 0 }, + }, + .palette => |idx| .{ + .tag = .palette, + .value = .{ .palette = idx }, + }, + .rgb => |rgb| .{ + .tag = .rgb, + .value = .{ .rgb = rgb.cval() }, + }, + }; + } +}; + +/// C: GhosttyStyle +pub const Style = extern struct { + size: usize = @sizeOf(Style), + fg_color: Color, + bg_color: Color, + underline_color: Color, + bold: bool, + italic: bool, + faint: bool, + blink: bool, + inverse: bool, + invisible: bool, + strikethrough: bool, + overline: bool, + underline: c_int, + + pub fn fromStyle(s: style.Style) Style { + return .{ + .fg_color = .fromColor(s.fg_color), + .bg_color = .fromColor(s.bg_color), + .underline_color = .fromColor(s.underline_color), + .bold = s.flags.bold, + .italic = s.flags.italic, + .faint = s.flags.faint, + .blink = s.flags.blink, + .inverse = s.flags.inverse, + .invisible = s.flags.invisible, + .strikethrough = s.flags.strikethrough, + .overline = s.flags.overline, + .underline = @intFromEnum(s.flags.underline), + }; + } +}; + +/// Returns the default style. +pub fn default_style(result: *Style) callconv(lib.calling_conv) void { + result.* = .fromStyle(.{}); + assert(result.size == @sizeOf(Style)); +} + +/// Returns true if the style is the default style. +pub fn style_is_default(s: *const Style) callconv(lib.calling_conv) bool { + assert(s.size == @sizeOf(Style)); + return s.fg_color.tag == .none and + s.bg_color.tag == .none and + s.underline_color.tag == .none and + s.bold == false and + s.italic == false and + s.faint == false and + s.blink == false and + s.inverse == false and + s.invisible == false and + s.strikethrough == false and + s.overline == false and + s.underline == 0; +} + +test "default style" { + var s: Style = undefined; + default_style(&s); + try testing.expect(style_is_default(&s)); + try testing.expectEqual(ColorTag.none, s.fg_color.tag); + try testing.expectEqual(ColorTag.none, s.bg_color.tag); + try testing.expectEqual(ColorTag.none, s.underline_color.tag); + try testing.expect(!s.bold); + try testing.expect(!s.italic); + try testing.expectEqual(@as(c_int, 0), s.underline); +} + +test "convert style with colors" { + const zig_style: style.Style = .{ + .fg_color = .{ .palette = 42 }, + .bg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } }, + .underline_color = .none, + .flags = .{ .bold = true, .underline = .curly }, + }; + + const c_style: Style = .fromStyle(zig_style); + try testing.expectEqual(ColorTag.palette, c_style.fg_color.tag); + try testing.expectEqual(@as(u8, 42), c_style.fg_color.value.palette); + try testing.expectEqual(ColorTag.rgb, c_style.bg_color.tag); + try testing.expectEqual(@as(u8, 255), c_style.bg_color.value.rgb.r); + try testing.expectEqual(@as(u8, 128), c_style.bg_color.value.rgb.g); + try testing.expectEqual(@as(u8, 64), c_style.bg_color.value.rgb.b); + try testing.expectEqual(ColorTag.none, c_style.underline_color.tag); + try testing.expect(c_style.bold); + try testing.expectEqual(@as(c_int, 3), c_style.underline); + try testing.expect(!style_is_default(&c_style)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig new file mode 100644 index 00000000..ec749ffa --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -0,0 +1,2358 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const ZigTerminal = @import("../Terminal.zig"); +const Stream = @import("../stream_terminal.zig").Stream; +const ScreenSet = @import("../ScreenSet.zig"); +const PageList = @import("../PageList.zig"); +const kitty = @import("../kitty/key.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 style_c = @import("style.zig"); +const color = @import("../color.zig"); +const Result = @import("result.zig").Result; + +const Handler = @import("../stream_terminal.zig").Handler; + +const log = std.log.scoped(.terminal_c); + +/// 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, + stream: Stream, + effects: Effects = .{}, +}; + +/// C callback state for terminal effects. Trampolines are always +/// installed on the stream handler; they check these fields and +/// no-op when the corresponding callback is null. +const Effects = struct { + userdata: ?*anyopaque = null, + write_pty: ?WritePtyFn = null, + bell: ?BellFn = null, + color_scheme: ?ColorSchemeFn = null, + device_attributes_cb: ?DeviceAttributesFn = null, + enquiry: ?EnquiryFn = null, + xtversion: ?XtversionFn = null, + title_changed: ?TitleChangedFn = null, + size_cb: ?SizeFn = null, + + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns + /// a slice pointing into it. Storing it here ensures the slice + /// remains valid after the trampoline returns, since the caller + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; + + /// C function pointer type for the bell callback. + pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + + /// C function pointer type for the color_scheme callback. + /// Returns true and fills out_scheme if a color scheme is available, + /// or returns false to silently ignore the query. + pub const ColorSchemeFn = *const fn (Terminal, ?*anyopaque, *device_status.ColorScheme) callconv(lib.calling_conv) bool; + + /// C function pointer type for the enquiry callback. + /// Returns the response bytes. The memory must remain valid + /// until the callback returns. + pub const EnquiryFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + + /// C function pointer type for the xtversion callback. + /// Returns the version string (e.g. "ghostty 1.2.3"). The memory + /// must remain valid until the callback returns. An empty string + /// (len=0) causes the default "libghostty" to be reported. + pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + + /// C function pointer type for the title_changed callback. + pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + + /// C function pointer type for the size callback. + /// Returns true and fills out_size if size is available, + /// or returns false to silently ignore the query. + pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(lib.calling_conv) bool; + + /// C function pointer type for the device_attributes callback. + /// Returns true and fills out_attrs if attributes are available, + /// or returns false to silently ignore the query. + pub const DeviceAttributesFn = *const fn (Terminal, ?*anyopaque, *CDeviceAttributes) callconv(lib.calling_conv) bool; + + /// C-compatible device attributes struct. + /// C: GhosttyDeviceAttributes + pub const CDeviceAttributes = extern struct { + primary: Primary, + secondary: Secondary, + tertiary: Tertiary, + + pub const Primary = extern struct { + conformance_level: u16, + features: [64]u16, + num_features: usize, + }; + + pub const Secondary = extern struct { + device_type: u16, + firmware_version: u16, + rom_cartridge: u16, + }; + + pub const Tertiary = extern struct { + unit_id: u32, + }; + }; + + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.write_pty orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } + + fn bellTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.bell orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + 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 stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + 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 stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + 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 stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + 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 stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.title_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn sizeTrampoline(handler: *Handler) ?size_report.Size { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + 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: GhosttyTerminalOptions +pub const Options = extern struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize, +}; + +const NewError = error{ + InvalidValue, + OutOfMemory, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Terminal, + opts: Options, +) callconv(lib.calling_conv) Result { + result.* = new_(alloc_, opts) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_( + alloc_: ?*const CAllocator, + opts: Options, +) NewError!*TerminalWrapper { + if (opts.cols == 0 or opts.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 wrapper = alloc.create(TerminalWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(wrapper); + + // Setup our terminal + t.* = try .init(alloc, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, + }); + errdefer t.deinit(alloc); + + // Setup our stream with trampolines always installed so that + // setting C callbacks at any time takes effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects = .{ + .write_pty = &Effects.writePtyTrampoline, + .bell = &Effects.bellTrampoline, + .color_scheme = &Effects.colorSchemeTrampoline, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .size = &Effects.sizeTrampoline, + }; + + wrapper.* = .{ + .terminal = t, + .stream = .initAlloc(alloc, handler), + }; + + return wrapper; +} + +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]); +} + +/// 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, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .write_pty => ?Effects.WritePtyFn, + .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, + .device_attributes => ?Effects.DeviceAttributesFn, + .enquiry => ?Effects.EnquiryFn, + .xtversion => ?Effects.XtversionFn, + .title_changed => ?Effects.TitleChangedFn, + .size_cb => ?Effects.SizeFn, + .title, .pwd => ?*const lib.String, + .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, + .color_palette => ?*const color.PaletteC, + }; + } +}; + +pub fn set( + terminal_: Terminal, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + const wrapper = terminal_ orelse return .invalid_value; + + return switch (option) { + inline else => |comptime_option| setTyped( + wrapper, + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + wrapper: *TerminalWrapper, + comptime option: Option, + value: option.InType(), +) Result { + switch (option) { + .userdata => wrapper.effects.userdata = @constCast(value), + .write_pty => wrapper.effects.write_pty = value, + .bell => wrapper.effects.bell = value, + .color_scheme => wrapper.effects.color_scheme = value, + .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, + .size_cb => wrapper.effects.size_cb = value, + .title => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + wrapper.terminal.setTitle(str) catch return .out_of_memory; + }, + .pwd => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + wrapper.terminal.setPwd(str) catch return .out_of_memory; + }, + .color_foreground => { + wrapper.terminal.colors.foreground.default = if (value) |v| .fromC(v.*) else null; + wrapper.terminal.flags.dirty.palette = true; + }, + .color_background => { + wrapper.terminal.colors.background.default = if (value) |v| .fromC(v.*) else null; + wrapper.terminal.flags.dirty.palette = true; + }, + .color_cursor => { + wrapper.terminal.colors.cursor.default = if (value) |v| .fromC(v.*) else null; + wrapper.terminal.flags.dirty.palette = true; + }, + .color_palette => { + wrapper.terminal.colors.palette.changeDefault( + if (value) |v| color.paletteZval(v) else color.default, + ); + wrapper.terminal.flags.dirty.palette = true; + }, + } + return .success; +} + +/// 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 }, + }); +} + +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; + const t = wrapper.terminal; + if (cols == 0 or rows == 0) return .invalid_value; + t.resize(t.gpa(), cols, rows) catch return .out_of_memory; + + // Update pixel sizes + t.width_px = std.math.mul(u32, cols, cell_width_px) catch std.math.maxInt(u32); + t.height_px = std.math.mul(u32, rows, cell_height_px) catch std.math.maxInt(u32); + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + t.modes.set(.synchronized_output, false); + + // If we have in-band size reporting enabled, send a report. + if (t.modes.get(.in_band_size_reports)) in_band: { + const func = wrapper.effects.write_pty orelse break :in_band; + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + size_report.encode(&writer, .mode_2048, .{ + .rows = rows, + .columns = cols, + .cell_width = cell_width_px, + .cell_height = cell_height_px, + }) catch break :in_band; + + const data = writer.buffered(); + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } + + return .success; +} + +pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { + const t: *ZigTerminal = (terminal_ orelse return).terminal; + t.fullReset(); +} + +pub fn mode_get( + terminal_: Terminal, + tag: modes.ModeTag.Backing, + out_value: *bool, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const mode_tag: modes.ModeTag = @bitCast(tag); + const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; + out_value.* = t.modes.get(mode); + return .success; +} + +pub fn mode_set( + terminal_: Terminal, + tag: modes.ModeTag.Backing, + value: bool, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const mode_tag: modes.ModeTag = @bitCast(tag); + const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; + t.modes.set(mode, value); + return .success; +} + +/// 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, + + /// 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 => bool, + .active_screen => TerminalScreen, + .kitty_keyboard_flags => u8, + .scrollbar => TerminalScrollbar, + .cursor_style => style_c.Style, + .title, .pwd => lib.String, + .total_rows, .scrollback_rows => 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, + }; + } +}; + +pub fn get( + terminal_: Terminal, + data: TerminalData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TerminalData, @intFromEnum(data)) catch { + log.warn("terminal_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + terminal_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + terminal_: Terminal, + comptime data: TerminalData, + out: *data.OutType(), +) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).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), + } + + 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 = switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + 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 free(terminal_: Terminal) callconv(lib.calling_conv) void { + const wrapper = terminal_ orelse return; + const t = wrapper.terminal; + + wrapper.stream.deinit(); + const alloc = t.gpa(); + t.deinit(alloc); + alloc.destroy(t); + alloc.destroy(wrapper); +} + +test "new/free" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + + 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, + .{ + .cols = 0, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); + + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 0, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); +} + +test "free null" { + free(null); +} + +test "scroll_viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?.terminal; + + // Write "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 }); + { + 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 }); + { + 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 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "scroll_viewport null" { + scroll_viewport(null, .{ .tag = .top, .value = undefined }); +} + +test "reset" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + 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 "mode_get and mode_set" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var value: bool = undefined; + + // DEC mode 25 (cursor_visible) defaults to true + const cursor_visible: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + try testing.expectEqual(Result.success, mode_get(t, cursor_visible, &value)); + try testing.expect(value); + + // Set it to false + try testing.expectEqual(Result.success, mode_set(t, cursor_visible, false)); + try testing.expectEqual(Result.success, mode_get(t, cursor_visible, &value)); + try testing.expect(!value); + + // ANSI mode 4 (insert) defaults to false + const insert: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true }); + try testing.expectEqual(Result.success, mode_get(t, insert, &value)); + try testing.expect(!value); + + try testing.expectEqual(Result.success, mode_set(t, insert, true)); + try testing.expectEqual(Result.success, mode_get(t, insert, &value)); + try testing.expect(value); +} + +test "mode_get null" { + var value: bool = undefined; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + try testing.expectEqual(Result.invalid_value, mode_get(null, tag, &value)); +} + +test "mode_set null" { + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + try testing.expectEqual(Result.invalid_value, mode_set(null, tag, true)); +} + +test "mode_get unknown mode" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var value: bool = undefined; + const unknown: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); + try testing.expectEqual(Result.invalid_value, mode_get(t, unknown, &value)); +} + +test "mode_set unknown mode" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const unknown: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); + try testing.expectEqual(Result.invalid_value, mode_set(t, unknown, true)); +} + +test "vt_write" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "vt_write split escape sequence" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + 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 "get cols and rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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 + const cursor_visible_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, cursor_visible_mode, false)); + try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible))); + try testing.expect(!visible); +} + +test "get active_screen" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var flags: u8 = undefined; + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(0, flags); + + // Push kitty flags via VT sequence: CSI > 3 u (push disambiguate | report_events) + vt_write(t, "\x1b[>3u", 5); + + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(3, flags); +} + +test "get mouse_tracking" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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) + const x10_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, x10_mode, true)); + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); + try testing.expect(tracking); + + // Disable X10, enable normal mouse (DEC mode 1000) + try testing.expectEqual(Result.success, mode_set(t, x10_mode, false)); + const normal_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1000, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, normal_mode, true)); + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); + try testing.expect(tracking); + + // Disable normal, enable button mouse (DEC mode 1002) + try testing.expectEqual(Result.success, mode_set(t, normal_mode, false)); + const button_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1002, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, button_mode, true)); + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); + try testing.expect(tracking); + + // Disable button, enable any mouse (DEC mode 1003) + try testing.expectEqual(Result.success, mode_set(t, button_mode, false)); + const any_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1003, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, any_mode, true)); + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); + try testing.expect(tracking); + + // Disable all - should be false again + try testing.expectEqual(Result.success, mode_set(t, any_mode, false)); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + 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, + .{ + .cols = 80, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + 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 invalid" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); +} + +test "grid_ref" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + 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 "set write_pty callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var 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 "set write_pty without callback ignores queries" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + // Set write_pty but not xtversion — should get default "libghostty" + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); +} + +test "set title_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 2 without a callback should not crash + vt_write(t, "\x1B]2;Hello\x1B\\", 10); +} + +test "set size callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // CSI 18 t without a size callback should not crash + vt_write(t, "\x1B[18t", 5); +} + +test "set device_attributes callback primary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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 "resize updates pixel dimensions" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + + const zt = t.?.terminal; + try testing.expectEqual(@as(u32, 100 * 9), zt.width_px); + try testing.expectEqual(@as(u32, 40 * 18), zt.height_px); +} + +test "resize pixel overflow saturates" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const zt = t.?.terminal; + zt.modes.set(.synchronized_output, true); + + try testing.expectEqual(Result.success, resize(t, 100, 40, 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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); +} diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig new file mode 100644 index 00000000..b808bf38 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/types.zig @@ -0,0 +1,215 @@ +//! Comptime-generated metadata describing the layout of all C API +//! extern structs for the current target. +//! +//! This is embedded in the binary as a const string and exposed via +//! `ghostty_type_json` so that WASM (and other FFI) consumers can +//! build structs without hardcoding byte offsets. +const std = @import("std"); +const lib = @import("../lib.zig"); +const color = @import("../color.zig"); +const mouse_event = @import("mouse_event.zig"); +const point = @import("../point.zig"); +const size_report = @import("size_report.zig"); + +const terminal = @import("terminal.zig"); +const formatter = @import("formatter.zig"); +const render = @import("render.zig"); +const style_c = @import("style.zig"); +const mouse_encode = @import("mouse_encode.zig"); +const grid_ref = @import("grid_ref.zig"); + +/// All C API structs and their Ghostty C names. +pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ + .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, + .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, + .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, + .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, + .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, + .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, + .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, + .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, + .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, + .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, + .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, + .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, + .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, + .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, + .{ "GhosttyString", StructInfo.init(lib.String) }, + .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, + .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, + .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, + .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, + .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, +}); + +/// The comptime-generated JSON string of all structs. +pub const json: [:0]const u8 = json: { + @setEvalBranchQuota(100000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + jsonWriteAll(&counter.writer) catch unreachable; + + var buf: [counter.count:0]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + jsonWriteAll(&writer) catch unreachable; + const final = buf; + break :json final[0..writer.end :0]; +}; + +/// Returns a pointer to the comptime-generated JSON string describing +/// the layout of all C API extern structs, and writes its length to `len`. +/// Exported as `ghostty_type_json` for FFI consumers. +pub fn get_json() callconv(lib.calling_conv) [*:0]const u8 { + return json.ptr; +} + +/// Meta information about a struct that we expose to ease writing +/// bindings in some languages, particularly WASM where we can't +/// easily share struct definitions and need to hardcode byte offsets. +pub const StructInfo = struct { + name: []const u8, + size: usize, + @"align": usize, + fields: []const FieldInfo, + + pub const FieldInfo = struct { + name: []const u8, + offset: usize, + size: usize, + type: []const u8, + }; + + pub fn init(comptime T: type) StructInfo { + comptime { + const fields = @typeInfo(T).@"struct".fields; + const field_infos: [fields.len]FieldInfo = blk: { + var infos: [fields.len]FieldInfo = undefined; + for (fields, 0..) |field, i| infos[i] = .{ + .name = field.name, + .offset = @offsetOf(T, field.name), + .size = @sizeOf(field.type), + .type = typeName(field.type), + }; + break :blk infos; + }; + + return .{ + .name = @typeName(T), + .size = @sizeOf(T), + .@"align" = @alignOf(T), + .fields = &field_infos, + }; + } + } + + pub fn jsonStringify( + self: *const StructInfo, + jws: anytype, + ) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("size"); + try jws.write(self.size); + try jws.objectField("align"); + try jws.write(self.@"align"); + try jws.objectField("fields"); + try jws.beginObject(); + for (self.fields) |field| { + try jws.objectField(field.name); + try jws.beginObject(); + try jws.objectField("offset"); + try jws.write(field.offset); + try jws.objectField("size"); + try jws.write(field.size); + try jws.objectField("type"); + try jws.write(field.type); + try jws.endObject(); + } + try jws.endObject(); + try jws.endObject(); + } +}; + +fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { + var jws: std.json.Stringify = .{ .writer = writer }; + try jws.beginObject(); + for (structs.keys(), structs.values()) |name, *info| { + try jws.objectField(name); + try info.jsonStringify(&jws); + } + try jws.endObject(); +} + +fn typeName(comptime T: type) []const u8 { + return switch (@typeInfo(T)) { + .bool => "bool", + .int => |info| switch (info.signedness) { + .signed => switch (info.bits) { + 8 => "i8", + 16 => "i16", + 32 => "i32", + 64 => "i64", + else => @compileError("unsupported signed int size"), + }, + .unsigned => switch (info.bits) { + 8 => "u8", + 16 => "u16", + 32 => "u32", + 64 => "u64", + else => @compileError("unsupported unsigned int size"), + }, + }, + .@"enum" => "enum", + .@"struct" => "struct", + .pointer => "pointer", + .array => "array", + else => "opaque", + }; +} + +test "json parses" { + const parsed = try std.json.parseFromSlice( + std.json.Value, + std.testing.allocator, + json, + .{}, + ); + defer parsed.deinit(); + + const root = parsed.value.object; + + // Verify we have all expected structs + try std.testing.expect(root.contains("GhosttyTerminalOptions")); + try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); + + // Verify GhosttyTerminalOptions fields + const term_opts = root.get("GhosttyTerminalOptions").?.object; + try std.testing.expect(term_opts.contains("size")); + try std.testing.expect(term_opts.contains("align")); + try std.testing.expect(term_opts.contains("fields")); + + const fields = term_opts.get("fields").?.object; + try std.testing.expect(fields.contains("cols")); + try std.testing.expect(fields.contains("rows")); + try std.testing.expect(fields.contains("max_scrollback")); + + // Verify field offsets make sense (cols should be at 0) + const cols = fields.get("cols").?.object; + try std.testing.expectEqual(0, cols.get("offset").?.integer); +} + +test "struct sizes are non-zero" { + const parsed = try std.json.parseFromSlice( + std.json.Value, + std.testing.allocator, + json, + .{}, + ); + defer parsed.deinit(); + + var it = parsed.value.object.iterator(); + while (it.next()) |entry| { + const struct_info = entry.value_ptr.object; + const size = struct_info.get("size").?.integer; + try std.testing.expect(size > 0); + } +} |
