src/vt.zig
Ref: Size: 16.1 KiB
//! Thin facade over the ghostty-vt Zig module.
//!
//! ## ghostty-vt API notes (as of ghostty-1.3.2-dev)
//!
//! ### Terminal lifecycle
//! const t = try Terminal.init(alloc, .{ .cols = N, .rows = N });
//! defer t.deinit();
//! try t.printString("raw text"); // writes plain text
//! try t.resize(alloc, new_cols, new_rows);
//! const s = try t.plainString(alloc); // heap copy of visible text
//! defer alloc.free(s);
//!
//! ### VT stream processing
//! TerminalStream wraps a Terminal and processes raw VT byte sequences.
//! Stream is the lower-level parser/dispatcher.
//!
//! ### RenderState (stateful renderer helper)
//! var state: RenderState = .empty;
//! defer state.deinit(alloc);
//! try state.update(alloc, &terminal);
//! // state.rows (CellCountInt), state.cols, state.colors, state.cursor
//! // state.row_data: std.MultiArrayList(RenderState.Row)
//! // state.dirty: RenderState.Dirty — caller should clear after use
//!
//! ### Input encoding
//! ghostty_vt.input.encodeKey(buf, key_event, opts) -> encode a KeyEvent
//! ghostty_vt.input.encodeMouse(buf, event, opts) -> encode a mouse event
//! ghostty_vt.input.encodeFocus(buf, event) -> encode focus in/out
//! ghostty_vt.input.encodePaste(buf, data, opts) -> bracketed-paste wrap
//!
//! ### Key types
//! input.Key, input.KeyAction, input.KeyEvent, input.KeyMods
//! input.KeyEncodeOptions
//!
//! ### Mouse types
//! input.MouseAction, input.MouseButton, input.MouseEncodeOptions,
//! input.MouseEncodeEvent
//!
//! ### Namespaces re-exported at top level
//! apc, dcs, osc, point, color, device_status, formatter, highlight,
//! kitty, modes, page, parse_table, search, sgr, size, x11_color, sys
//!
//! ### Sizes
//! size.CellCountInt — integer type for column/row counts
//!
//! ### Cursor
//! Screen.Cursor / Cursor — position and style within a Screen
//! CursorStyle, CursorStyleReq
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const color = ghostty_vt.color;
const DeviceAttributesCallback = std.meta.Child(
@FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
);
const DeviceAttributesReturn = @typeInfo(
std.meta.Child(DeviceAttributesCallback),
).@"fn".return_type.?;
pub const InputAction = ghostty_vt.input.KeyAction;
pub const InputKey = ghostty_vt.input.Key;
pub const InputMods = ghostty_vt.input.KeyMods;
pub const RenderDirty = ghostty_vt.RenderState.Dirty;
pub const Size = ghostty_vt.size_report.Size;
pub const CellColors = struct {
fg: [4]f32,
bg: [4]f32,
};
pub const Terminal = struct {
pub const WritePtyFn = *const fn (*Terminal, ?*anyopaque, []const u8) void;
pub const TitleChangedFn = *const fn (*Terminal, ?*anyopaque, ?[:0]const u8) void;
const Hooks = struct {
write_pty_ctx: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
title_changed_ctx: ?*anyopaque = null,
title_changed: ?TitleChangedFn = null,
reported_size: ?Size = null,
};
alloc: std.mem.Allocator,
inner: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
render_state: ghostty_vt.RenderState,
hooks: Hooks = .{},
pub const InitOptions = struct {
cols: u16,
rows: u16,
max_scrollback: u32 = 1000,
};
pub fn init(alloc: std.mem.Allocator, opts: InitOptions) !*Terminal {
const self = try alloc.create(Terminal);
errdefer alloc.destroy(self);
const inner = try ghostty_vt.Terminal.init(alloc, .{
.cols = @intCast(opts.cols),
.rows = @intCast(opts.rows),
.max_scrollback = @intCast(opts.max_scrollback),
.colors = .{
.background = color.DynamicRGB.init(.{ .r = 0x00, .g = 0x00, .b = 0x00 }),
.foreground = color.DynamicRGB.init(.{ .r = 0xff, .g = 0xff, .b = 0xff }),
.cursor = .unset,
.palette = .default,
},
});
errdefer inner.deinit(alloc);
self.* = .{
.alloc = alloc,
.inner = inner,
.stream = .initAlloc(alloc, .{
.terminal = &self.inner,
}),
.render_state = .empty,
.hooks = .{},
};
self.stream.handler.effects.write_pty = &streamWritePty;
self.stream.handler.effects.title_changed = &streamTitleChanged;
self.stream.handler.effects.size = &streamSize;
self.stream.handler.effects.device_attributes = &streamDeviceAttributes;
self.stream.handler.effects.xtversion = &streamXtversion;
self.stream.handler.effects.color_scheme = &streamColorScheme;
return self;
}
pub fn deinit(self: *Terminal) void {
self.render_state.deinit(self.alloc);
self.inner.deinit(self.alloc);
self.alloc.destroy(self);
}
pub fn write(self: *Terminal, bytes: []const u8) void {
self.stream.nextSlice(bytes);
}
pub fn setWritePtyCallback(
self: *Terminal,
ctx: ?*anyopaque,
callback: ?WritePtyFn,
) void {
self.hooks.write_pty_ctx = ctx;
self.hooks.write_pty = callback;
}
pub fn setTitleChangedCallback(
self: *Terminal,
ctx: ?*anyopaque,
callback: ?TitleChangedFn,
) void {
self.hooks.title_changed_ctx = ctx;
self.hooks.title_changed = callback;
}
pub fn setReportedSize(self: *Terminal, size: Size) void {
self.hooks.reported_size = size;
}
pub fn encodeKey(
self: *const Terminal,
key: InputKey,
mods: InputMods,
action: InputAction,
buf: []u8,
) ![]const u8 {
var writer: std.Io.Writer = .fixed(buf);
try ghostty_vt.input.encodeKey(&writer, .{
.action = action,
.key = key,
.mods = mods,
.consumed_mods = .{},
}, .fromTerminal(&self.inner));
return writer.buffered();
}
pub fn encodePaste(
self: *const Terminal,
data: []u8,
) [3][]const u8 {
return ghostty_vt.input.encodePaste(data, .fromTerminal(&self.inner));
}
pub fn cellColors(
self: *const Terminal,
cell: ghostty_vt.RenderState.Cell,
) CellColors {
const style: ghostty_vt.Style = if (cell.raw.style_id != 0) cell.style else .{};
const colors = self.render_state.colors;
const fg = style.fg(.{
.default = colors.foreground,
.palette = &colors.palette,
});
const bg = style.bg(&cell.raw, &colors.palette) orelse colors.background;
return .{
.fg = rgbToFloat4(fg),
.bg = rgbToFloat4(bg),
};
}
pub fn resize(self: *Terminal, new_cols: u16, new_rows: u16) !void {
try self.inner.resize(self.alloc, @intCast(new_cols), @intCast(new_rows));
}
pub fn snapshot(self: *Terminal) !void {
try self.render_state.update(self.alloc, &self.inner);
}
pub fn backgroundColor(self: *const Terminal) [4]f32 {
return rgbToFloat4(self.render_state.colors.background);
}
/// Number of columns in the terminal grid.
pub fn cols(self: *const Terminal) u16 {
return @intCast(self.render_state.cols);
}
/// Number of rows in the terminal grid.
pub fn rows(self: *const Terminal) u16 {
return @intCast(self.render_state.rows);
}
};
fn rgbToFloat4(rgb: ghostty_vt.color.RGB) [4]f32 {
return .{
@as(f32, @floatFromInt(rgb.r)) / 255.0,
@as(f32, @floatFromInt(rgb.g)) / 255.0,
@as(f32, @floatFromInt(rgb.b)) / 255.0,
1.0,
};
}
fn terminalFromHandler(handler: *ghostty_vt.TerminalStream.Handler) *Terminal {
const inner: *ghostty_vt.Terminal = handler.terminal;
return @fieldParentPtr("inner", inner);
}
fn streamWritePty(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
const self = terminalFromHandler(handler);
const callback = self.hooks.write_pty orelse return;
callback(self, self.hooks.write_pty_ctx, data);
}
fn streamTitleChanged(handler: *ghostty_vt.TerminalStream.Handler) void {
const self = terminalFromHandler(handler);
const callback = self.hooks.title_changed orelse return;
callback(self, self.hooks.title_changed_ctx, self.inner.getTitle());
}
fn streamSize(handler: *ghostty_vt.TerminalStream.Handler) ?Size {
const self = terminalFromHandler(handler);
return self.hooks.reported_size;
}
fn streamDeviceAttributes(_: *ghostty_vt.TerminalStream.Handler) DeviceAttributesReturn {
return .{};
}
fn streamXtversion(_: *ghostty_vt.TerminalStream.Handler) []const u8 {
return "waystty";
}
fn streamColorScheme(_: *ghostty_vt.TerminalStream.Handler) ?ghostty_vt.device_status.ColorScheme {
return null;
}
test "Terminal init/deinit" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
try std.testing.expectEqual(&term.inner, term.stream.handler.terminal);
}
test "Terminal.write feeds plain text" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("Hello");
try term.snapshot();
// The test just verifies the call sequence works without crashing.
// We don't yet have a way to read back individual cells in this task.
}
test "Terminal.resize" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
try term.resize(120, 40);
try term.snapshot();
try std.testing.expectEqual(@as(u16, 120), term.cols());
try std.testing.expectEqual(@as(u16, 40), term.rows());
}
test "Terminal encodes non-text arrow keys" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
var buf: [32]u8 = undefined;
const encoded = try term.encodeKey(.arrow_left, .{}, .press, &buf);
try std.testing.expectEqualStrings("\x1b[D", encoded);
}
test "Terminal resolves ANSI fg and bg colors from render state" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b[31;44mA\x1b[0m");
try term.snapshot();
const cell = term.render_state.row_data.get(0).cells.get(0);
const colors = term.cellColors(cell);
const palette = term.render_state.colors.palette;
try std.testing.expectEqualDeep(rgbToFloat4(palette[1]), colors.fg);
try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg);
}
test "Terminal applies OSC 11 background color updates" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b]11;rgb:12/34/56\x1b\\");
try term.snapshot();
try std.testing.expectEqualDeep(
[_]f32{
@as(f32, 0x12) / 255.0,
@as(f32, 0x34) / 255.0,
@as(f32, 0x56) / 255.0,
1.0,
},
term.backgroundColor(),
);
}
test "Terminal resolves color-only cells without reading undefined style state" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b[44m\x1b[K");
try term.snapshot();
const cell = term.render_state.row_data.get(0).cells.get(0);
const colors = term.cellColors(cell);
const palette = term.render_state.colors.palette;
try std.testing.expectEqualDeep(rgbToFloat4(term.render_state.colors.foreground), colors.fg);
try std.testing.expectEqualDeep(rgbToFloat4(palette[4]), colors.bg);
}
test "Terminal title callback fires on OSC 2" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var title: ?[]const u8 = null;
fn onTitle(_: *Terminal, _: ?*anyopaque, value: ?[:0]const u8) void {
title = if (value) |v| v else null;
}
};
S.title = null;
term.setTitleChangedCallback(null, &S.onTitle);
term.write("\x1b]2;waystty\x1b\\");
try std.testing.expectEqualStrings("waystty", S.title.?);
}
test "Terminal title callback fires on empty OSC 2" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var saw_callback = false;
var title_is_null = false;
fn onTitle(_: *Terminal, _: ?*anyopaque, value: ?[:0]const u8) void {
saw_callback = true;
title_is_null = value == null;
}
};
S.saw_callback = false;
S.title_is_null = false;
term.setTitleChangedCallback(null, &S.onTitle);
term.write("\x1b]2;\x1b\\");
try std.testing.expect(S.saw_callback);
try std.testing.expect(S.title_is_null);
}
test "Terminal write_pty callback emits cursor position reports" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var written: [128]u8 = undefined;
var written_len: usize = 0;
fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void {
@memcpy(written[0..data.len], data);
written_len = data.len;
}
};
S.written_len = 0;
term.setWritePtyCallback(null, &S.onWrite);
term.write("\x1b[6n");
try std.testing.expectEqualStrings("\x1b[1;1R", S.written[0..S.written_len]);
}
test "Terminal reports waystty for XTVERSION queries" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var written: [128]u8 = undefined;
var written_len: usize = 0;
fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void {
@memcpy(written[0..data.len], data);
written_len = data.len;
}
};
S.written_len = 0;
term.setWritePtyCallback(null, &S.onWrite);
term.write("\x1b[>0q");
try std.testing.expectEqualStrings("\x1bP>|waystty\x1b\\", S.written[0..S.written_len]);
}
test "Terminal reports configured size queries" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var written: [128]u8 = undefined;
var written_len: usize = 0;
fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void {
@memcpy(written[0..data.len], data);
written_len = data.len;
}
};
S.written_len = 0;
term.setWritePtyCallback(null, &S.onWrite);
term.setReportedSize(.{
.rows = 24,
.columns = 80,
.cell_width = 9,
.cell_height = 18,
});
term.write("\x1b[18t");
try std.testing.expectEqualStrings("\x1b[8;24;80t", S.written[0..S.written_len]);
}
test "Terminal reports default device attributes queries" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
const S = struct {
var buf: [64]u8 = undefined;
var written_len: usize = 0;
fn onWrite(_: *Terminal, _: ?*anyopaque, data: []const u8) void {
written_len = data.len;
@memcpy(buf[0..data.len], data);
}
};
S.written_len = 0;
term.setWritePtyCallback(null, &S.onWrite);
term.write("\x1b[c");
try std.testing.expectEqualStrings("\x1b[?62;22c", S.buf[0..S.written_len]);
}
test "Terminal encodes bracketed paste based on terminal mode" {
var term = try Terminal.init(std.testing.allocator, .{
.cols = 80,
.rows = 24,
});
defer term.deinit();
term.write("\x1b[?2004h");
const data = try std.testing.allocator.dupe(u8, "hello");
defer std.testing.allocator.free(data);
const encoded = term.encodePaste(data);
try std.testing.expectEqualStrings("\x1b[200~", encoded[0]);
try std.testing.expectEqualStrings("hello", encoded[1]);
try std.testing.expectEqualStrings("\x1b[201~", encoded[2]);
}