a73x

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