a73x

5a01c1f3

feat(vt): Terminal facade wrapping ghostty-vt

a73x   2026-04-08 06:08

Wraps ghostty_vt.Terminal + TerminalStream + RenderState into a single
Terminal struct with init/deinit/write/resize/snapshot/cols/rows API.

diff --git a/src/vt.zig b/src/vt.zig
index 512d818..b506046 100644
--- a/src/vt.zig
+++ b/src/vt.zig
@@ -1,4 +1,4 @@
//! Thin wrapper around the ghostty-vt Zig module.
//! Thin facade over the ghostty-vt Zig module.
//!
//! ## ghostty-vt API notes (as of ghostty-1.3.2-dev)
//!
@@ -50,7 +50,111 @@
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");

test "ghostty-vt module imports" {
    // Smoke test — just reference the module so the import is verified
    _ = ghostty_vt;
pub const Terminal = struct {
    alloc: std.mem.Allocator,
    inner: ghostty_vt.Terminal,
    stream: ghostty_vt.TerminalStream,
    render_state: ghostty_vt.RenderState,

    pub const InitOptions = struct {
        cols: u16,
        rows: u16,
        max_scrollback: u32 = 1000,
    };

    pub fn init(alloc: std.mem.Allocator, opts: InitOptions) !Terminal {
        var inner = try ghostty_vt.Terminal.init(alloc, .{
            .cols = @intCast(opts.cols),
            .rows = @intCast(opts.rows),
            .max_scrollback = @intCast(opts.max_scrollback),
        });
        errdefer inner.deinit(alloc);

        // TerminalStream.init takes a Handler value. Handler contains
        // a pointer to the terminal, so we store inner first then set
        // the pointer during return below — but we need a stable address.
        // We initialise with a placeholder and fix up the pointer after
        // the struct is placed in its final location by the caller.
        const stream: ghostty_vt.TerminalStream = .init(.{
            .terminal = &inner,
        });

        const render_state: ghostty_vt.RenderState = .empty;

        return .{
            .alloc = alloc,
            .inner = inner,
            .stream = stream,
            .render_state = render_state,
        };
    }

    /// Fix up the internal stream pointer after the Terminal has been moved
    /// to its final memory location. Must be called once before write().
    fn fixupStreamPointer(self: *Terminal) void {
        self.stream.handler.terminal = &self.inner;
    }

    pub fn deinit(self: *Terminal) void {
        self.render_state.deinit(self.alloc);
        self.inner.deinit(self.alloc);
    }

    pub fn write(self: *Terminal, bytes: []const u8) void {
        self.stream.handler.terminal = &self.inner;
        self.stream.nextSlice(bytes);
    }

    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);
    }

    /// 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);
    }
};

test "Terminal init/deinit" {
    var term = try Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();
}

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());
}