a73x

47eefde2

feat: full terminal integration — waystty works

a73x   2026-04-08 09:33

Wire all modules into a working terminal: font→atlas→PTY→vt→render loop.
Fix glyph shader to use per-instance glyph_size+bearing instead of cell_size,
update Instance struct and vertex attribute descriptions to match new layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/shaders/cell.vert b/shaders/cell.vert
index e82987b..dcfdd79 100644
--- a/shaders/cell.vert
+++ b/shaders/cell.vert
@@ -8,16 +8,20 @@ layout(push_constant) uniform PushConstants {
layout(location = 0) in vec2 in_unit_pos;

layout(location = 1) in vec2 in_cell_pos;
layout(location = 2) in vec4 in_uv_rect;
layout(location = 3) in vec4 in_fg_color;
layout(location = 4) in vec4 in_bg_color;
layout(location = 2) in vec2 in_glyph_size;
layout(location = 3) in vec2 in_glyph_bearing;
layout(location = 4) in vec4 in_uv_rect;
layout(location = 5) in vec4 in_fg_color;
layout(location = 6) in vec4 in_bg_color;

layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec4 out_fg;
layout(location = 2) out vec4 out_bg;

void main() {
    vec2 pixel_pos = (in_cell_pos + in_unit_pos) * pc.cell_size;
    vec2 cell_origin = in_cell_pos * pc.cell_size;
    vec2 glyph_origin = cell_origin + in_glyph_bearing;
    vec2 pixel_pos = glyph_origin + in_unit_pos * in_glyph_size;
    vec2 ndc = (pixel_pos / pc.viewport_size) * 2.0 - 1.0;
    gl_Position = vec4(ndc, 0.0, 1.0);

diff --git a/src/main.zig b/src/main.zig
index 5ef28ab..7c4ca2a 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -33,7 +33,181 @@ pub fn main() !void {
        return runDrawSmokeTest(alloc);
    }

    std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
    return runTerminal(alloc);
}

fn runTerminal(alloc: std.mem.Allocator) !void {
    // === font first, to know cell size ===
    var font_lookup = try font.lookupMonospace(alloc);
    defer font_lookup.deinit(alloc);

    const font_size: u32 = 16;
    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size);
    defer face.deinit();

    const cell_w = face.cellWidth();
    const cell_h = face.cellHeight();

    // === grid size ===
    const cols: u16 = 80;
    const rows: u16 = 24;
    const initial_w: u32 = @as(u32, cols) * cell_w;
    const initial_h: u32 = @as(u32, rows) * cell_h;

    // === wayland ===
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty");
    defer window.deinit();

    // Set window to desired terminal dimensions
    window.width = initial_w;
    window.height = initial_h;

    _ = conn.display.roundtrip();

    // === keyboard ===
    var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?);
    defer keyboard.deinit();

    // === renderer ===
    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    // === glyph atlas ===
    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    // Upload empty atlas first (so descriptor set is valid)
    try ctx.uploadAtlas(atlas.pixels);

    // === terminal ===
    var term = try vt.Terminal.init(alloc, .{
        .cols = cols,
        .rows = rows,
        .max_scrollback = 1000,
    });
    defer term.deinit();

    // === pty ===
    const shell: [:0]const u8 = blk: {
        const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
        break :blk try alloc.dupeZ(u8, shell_env);
    };
    defer alloc.free(shell);

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

    // === instance buffer ===
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer instances.deinit(alloc);
    try instances.ensureTotalCapacity(alloc, @as(usize, cols) * rows);

    // === main loop ===
    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd,       .events = std.posix.POLL.IN, .revents = 0 },
        .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };

    var read_buf: [8192]u8 = undefined;

    while (!window.should_close and p.isChildAlive()) {
        // Flush any pending wayland requests
        _ = conn.display.flush();

        // Poll with 16ms timeout so we get a render tick even without events
        _ = std.posix.poll(&pollfds, 16) catch {};

        // Wayland events: prepare_read / read_events / dispatch_pending
        if (pollfds[0].revents & std.posix.POLL.IN != 0) {
            if (conn.display.prepareRead()) {
                _ = conn.display.readEvents();
            }
        }
        _ = conn.display.dispatchPending();

        // PTY output
        if (pollfds[1].revents & std.posix.POLL.IN != 0) {
            while (true) {
                const n = p.read(&read_buf) catch |err| switch (err) {
                    error.WouldBlock => break,
                    error.InputOutput => break, // child likely exited
                    else => return err,
                };
                if (n == 0) break;
                term.write(read_buf[0..n]);
            }
        }

        // Keyboard events → write to pty
        keyboard.tickRepeat();
        for (keyboard.event_queue.items) |ev| {
            if (ev.action == .release) continue;
            if (ev.utf8_len > 0) {
                _ = try p.write(ev.utf8[0..ev.utf8_len]);
            }
            // Keys without UTF-8 (arrows, function keys) are skipped for v1
        }
        keyboard.event_queue.clearRetainingCapacity();

        // === render ===
        try term.snapshot();

        instances.clearRetainingCapacity();

        const term_rows = term.render_state.row_data.items(.cells);
        var row_idx: u32 = 0;
        while (row_idx < term_rows.len) : (row_idx += 1) {
            const raw_cells = term_rows[row_idx].items(.raw);
            var col_idx: u32 = 0;
            while (col_idx < raw_cells.len) : (col_idx += 1) {
                const cp = raw_cells[col_idx].codepoint();
                if (cp == 0 or cp == ' ') continue;

                const uv = atlas.getOrInsert(&face, @intCast(cp)) catch continue;

                try instances.append(alloc, .{
                    .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
                    .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
                    .glyph_bearing = .{
                        @floatFromInt(uv.bearing_x),
                        // bearing_y in freetype is from baseline going up; our quad origin is top-left going down
                        // cell_h - bearing_y gives the offset from the top of the cell
                        @as(f32, @floatFromInt(cell_h)) - @as(f32, @floatFromInt(uv.bearing_y)),
                    },
                    .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
                    .fg = .{ 0.9, 0.9, 0.9, 1.0 },
                    .bg = .{ 0.08, 0.08, 0.08, 1.0 },
                });
            }
        }

        // Re-upload atlas if new glyphs were added
        if (atlas.dirty) {
            try ctx.uploadAtlas(atlas.pixels);
            atlas.dirty = false;
        }

        if (instances.items.len > 0) {
            try ctx.uploadInstances(instances.items);
        }

        try ctx.drawCells(
            @intCast(instances.items.len),
            .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
        );
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}

fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
@@ -88,6 +262,11 @@ fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
    const instances = [_]renderer.Instance{
        .{
            .cell_pos = .{ 40.0, 12.0 },
            .glyph_size = .{ @floatFromInt(glyph_uv.width), @floatFromInt(glyph_uv.height) },
            .glyph_bearing = .{
                @floatFromInt(glyph_uv.bearing_x),
                @as(f32, @floatFromInt(face.cellHeight())) - @as(f32, @floatFromInt(glyph_uv.bearing_y)),
            },
            .uv_rect = .{ glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1 },
            .fg = .{ 1.0, 1.0, 1.0, 1.0 },
            .bg = .{ 0.0, 0.0, 0.0, 1.0 },
diff --git a/src/renderer.zig b/src/renderer.zig
index a8adf59..fc2def4 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -211,10 +211,12 @@ pub const Vertex = extern struct {

/// Per-instance data (binding 1, per-instance rate)
pub const Instance = extern struct {
    cell_pos: [2]f32, // location 1
    uv_rect: [4]f32, // location 2
    fg: [4]f32, // location 3
    bg: [4]f32, // location 4
    cell_pos: [2]f32,     // location 1
    glyph_size: [2]f32,   // location 2
    glyph_bearing: [2]f32, // location 3
    uv_rect: [4]f32,      // location 4
    fg: [4]f32,           // location 5
    bg: [4]f32,           // location 6
};

const BufferResult = struct {
@@ -533,9 +535,11 @@ pub const Context = struct {
        const attr_descs = [_]vk.VertexInputAttributeDescription{
            .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 },
            .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") },
            .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") },
            .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") },
            .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") },
            .{ .location = 2, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "glyph_size") },
            .{ .location = 3, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "glyph_bearing") },
            .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") },
            .{ .location = 5, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") },
            .{ .location = 6, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") },
        };

        const vertex_input_info = vk.PipelineVertexInputStateCreateInfo{
@@ -1085,7 +1089,7 @@ pub const Context = struct {
        });

        const clear_value = vk.ClearValue{
            .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } },
            .color = .{ .float_32 = .{ 0.08, 0.08, 0.08, 1.0 } },
        };

        self.vkd.cmdBeginRenderPass(self.command_buffer, &vk.RenderPassBeginInfo{