a73x

b2d1bc2e

Wire terminal effect callbacks

a73x   2026-04-08 13:09


diff --git a/src/main.zig b/src/main.zig
index 1060bb3..e057439 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -14,6 +14,19 @@ const GridSize = struct {
    rows: u16,
};

fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void {
    const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return));
    _ = p.write(data) catch |err| {
        std.log.warn("pty write callback failed: {}", .{err});
        return;
    };
}

fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) void {
    const window: *wayland_client.Window = @ptrCast(@alignCast(ctx orelse return));
    window.setTitle(title);
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
@@ -106,6 +119,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        .max_scrollback = 1000,
    });
    defer term.deinit();
    term.setTitleChangedCallback(window, &updateWindowTitle);
    term.setReportedSize(.{
        .rows = rows,
        .columns = cols,
        .cell_width = cell_w,
        .cell_height = cell_h,
    });

    // === pty ===
    const shell: [:0]const u8 = blk: {
@@ -116,6 +136,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {

    var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell });
    defer p.deinit();
    term.setWritePtyCallback(&p, &writePtyFromTerminal);

    // === instance buffer ===
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
@@ -183,6 +204,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                try p.resize(new_grid.cols, new_grid.rows);
                cols = new_grid.cols;
                rows = new_grid.rows;
                term.setReportedSize(.{
                    .rows = rows,
                    .columns = cols,
                    .cell_width = cell_w,
                    .cell_height = cell_h,
                });
            } else {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(window.width, window.height);
diff --git a/src/vt.zig b/src/vt.zig
index e0f67b4..a320058 100644
--- a/src/vt.zig
+++ b/src/vt.zig
@@ -49,20 +49,39 @@

const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
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 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,
@@ -94,6 +113,7 @@ pub const Terminal = struct {
            .inner = inner,
            .stream = stream,
            .render_state = render_state,
            .hooks = .{},
        };
    }

@@ -101,6 +121,12 @@ pub const Terminal = struct {
    /// to its final memory location. Must be called once before write().
    fn fixupStreamPointer(self: *Terminal) void {
        self.stream.handler.terminal = &self.inner;
        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;
    }

    pub fn deinit(self: *Terminal) void {
@@ -109,10 +135,35 @@ pub const Terminal = struct {
    }

    pub fn write(self: *Terminal, bytes: []const u8) void {
        self.stream.handler.terminal = &self.inner;
        self.fixupStreamPointer();
        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;
        self.fixupStreamPointer();
    }

    pub fn setTitleChangedCallback(
        self: *Terminal,
        ctx: ?*anyopaque,
        callback: ?TitleChangedFn,
    ) void {
        self.hooks.title_changed_ctx = ctx;
        self.hooks.title_changed = callback;
        self.fixupStreamPointer();
    }

    pub fn setReportedSize(self: *Terminal, size: Size) void {
        self.hooks.reported_size = size;
        self.fixupStreamPointer();
    }

    pub fn encodeKey(
        self: *const Terminal,
        key: InputKey,
@@ -182,6 +233,40 @@ fn rgbToFloat4(rgb: ghostty_vt.color.RGB) [4]f32 {
    };
}

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,
@@ -246,3 +331,153 @@ test "Terminal resolves ANSI fg and bg colors from render state" {
    try std.testing.expectEqualDeep(rgbToFloat4(palette[1]), 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]);
}
diff --git a/src/wayland.zig b/src/wayland.zig
index 0b9d5fa..6e4b2cf 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -127,6 +127,10 @@ pub const Window = struct {
        self.surface.destroy();
        self.alloc.destroy(self);
    }

    pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
        self.xdg_toplevel.setTitle((title orelse "waystty"));
    }
};

pub const Connection = struct {