47eefde2
feat: full terminal integration — waystty works
a73x 2026-04-08 09:33
Commit message
shaders/cell.vert
| Old | New | ||
|---|---|---|---|
| @@ -8,16 +8,20 @@ layout(push_constant) uniform PushConstants { | |||
| 8 | layout(location = 0) in vec2 in_unit_pos; | 8 | layout(location = 0) in vec2 in_unit_pos; |
| 9 | 9 | ||
| 10 | layout(location = 1) in vec2 in_cell_pos; | 10 | layout(location = 1) in vec2 in_cell_pos; |
| 11 | layout(location = 2) in vec4 in_uv_rect; | 11 | layout(location = 2) in vec2 in_glyph_size; |
| 12 | layout(location = 3) in vec4 in_fg_color; | 12 | layout(location = 3) in vec2 in_glyph_bearing; |
| 13 | layout(location = 4) in vec4 in_bg_color; | 13 | layout(location = 4) in vec4 in_uv_rect; |
| 14 | layout(location = 5) in vec4 in_fg_color; | ||
| 15 | layout(location = 6) in vec4 in_bg_color; | ||
| 14 | 16 | ||
| 15 | layout(location = 0) out vec2 out_uv; | 17 | layout(location = 0) out vec2 out_uv; |
| 16 | layout(location = 1) out vec4 out_fg; | 18 | layout(location = 1) out vec4 out_fg; |
| 17 | layout(location = 2) out vec4 out_bg; | 19 | layout(location = 2) out vec4 out_bg; |
| 18 | 20 | ||
| 19 | void main() { | 21 | void main() { |
| 20 | vec2 pixel_pos = (in_cell_pos + in_unit_pos) * pc.cell_size; | 22 | vec2 cell_origin = in_cell_pos * pc.cell_size; |
| 23 | vec2 glyph_origin = cell_origin + in_glyph_bearing; | ||
| 24 | vec2 pixel_pos = glyph_origin + in_unit_pos * in_glyph_size; | ||
| 21 | vec2 ndc = (pixel_pos / pc.viewport_size) * 2.0 - 1.0; | 25 | vec2 ndc = (pixel_pos / pc.viewport_size) * 2.0 - 1.0; |
| 22 | gl_Position = vec4(ndc, 0.0, 1.0); | 26 | gl_Position = vec4(ndc, 0.0, 1.0); |
| 23 | 27 | ||
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -33,7 +33,181 @@ pub fn main() !void { | |||
| 33 | return runDrawSmokeTest(alloc); | 33 | return runDrawSmokeTest(alloc); |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{}); | 36 | return runTerminal(alloc); |
| 37 | } | ||
| 38 | |||
| 39 | fn runTerminal(alloc: std.mem.Allocator) !void { | ||
| 40 | // === font first, to know cell size === | ||
| 41 | var font_lookup = try font.lookupMonospace(alloc); | ||
| 42 | defer font_lookup.deinit(alloc); | ||
| 43 | |||
| 44 | const font_size: u32 = 16; | ||
| 45 | var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size); | ||
| 46 | defer face.deinit(); | ||
| 47 | |||
| 48 | const cell_w = face.cellWidth(); | ||
| 49 | const cell_h = face.cellHeight(); | ||
| 50 | |||
| 51 | // === grid size === | ||
| 52 | const cols: u16 = 80; | ||
| 53 | const rows: u16 = 24; | ||
| 54 | const initial_w: u32 = @as(u32, cols) * cell_w; | ||
| 55 | const initial_h: u32 = @as(u32, rows) * cell_h; | ||
| 56 | |||
| 57 | // === wayland === | ||
| 58 | var conn = try wayland_client.Connection.init(); | ||
| 59 | defer conn.deinit(); | ||
| 60 | |||
| 61 | const window = try conn.createWindow(alloc, "waystty"); | ||
| 62 | defer window.deinit(); | ||
| 63 | |||
| 64 | // Set window to desired terminal dimensions | ||
| 65 | window.width = initial_w; | ||
| 66 | window.height = initial_h; | ||
| 67 | |||
| 68 | _ = conn.display.roundtrip(); | ||
| 69 | |||
| 70 | // === keyboard === | ||
| 71 | var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); | ||
| 72 | defer keyboard.deinit(); | ||
| 73 | |||
| 74 | // === renderer === | ||
| 75 | var ctx = try renderer.Context.init( | ||
| 76 | alloc, | ||
| 77 | @ptrCast(conn.display), | ||
| 78 | @ptrCast(window.surface), | ||
| 79 | window.width, | ||
| 80 | window.height, | ||
| 81 | ); | ||
| 82 | defer ctx.deinit(); | ||
| 83 | |||
| 84 | // === glyph atlas === | ||
| 85 | var atlas = try font.Atlas.init(alloc, 1024, 1024); | ||
| 86 | defer atlas.deinit(); | ||
| 87 | |||
| 88 | // Upload empty atlas first (so descriptor set is valid) | ||
| 89 | try ctx.uploadAtlas(atlas.pixels); | ||
| 90 | |||
| 91 | // === terminal === | ||
| 92 | var term = try vt.Terminal.init(alloc, .{ | ||
| 93 | .cols = cols, | ||
| 94 | .rows = rows, | ||
| 95 | .max_scrollback = 1000, | ||
| 96 | }); | ||
| 97 | defer term.deinit(); | ||
| 98 | |||
| 99 | // === pty === | ||
| 100 | const shell: [:0]const u8 = blk: { | ||
| 101 | const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh"; | ||
| 102 | break :blk try alloc.dupeZ(u8, shell_env); | ||
| 103 | }; | ||
| 104 | defer alloc.free(shell); | ||
| 105 | |||
| 106 | var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell }); | ||
| 107 | defer p.deinit(); | ||
| 108 | |||
| 109 | // === instance buffer === | ||
| 110 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | ||
| 111 | defer instances.deinit(alloc); | ||
| 112 | try instances.ensureTotalCapacity(alloc, @as(usize, cols) * rows); | ||
| 113 | |||
| 114 | // === main loop === | ||
| 115 | const wl_fd = conn.display.getFd(); | ||
| 116 | var pollfds = [_]std.posix.pollfd{ | ||
| 117 | .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 118 | .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 119 | }; | ||
| 120 | |||
| 121 | var read_buf: [8192]u8 = undefined; | ||
| 122 | |||
| 123 | while (!window.should_close and p.isChildAlive()) { | ||
| 124 | // Flush any pending wayland requests | ||
| 125 | _ = conn.display.flush(); | ||
| 126 | |||
| 127 | // Poll with 16ms timeout so we get a render tick even without events | ||
| 128 | _ = std.posix.poll(&pollfds, 16) catch {}; | ||
| 129 | |||
| 130 | // Wayland events: prepare_read / read_events / dispatch_pending | ||
| 131 | if (pollfds[0].revents & std.posix.POLL.IN != 0) { | ||
| 132 | if (conn.display.prepareRead()) { | ||
| 133 | _ = conn.display.readEvents(); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | _ = conn.display.dispatchPending(); | ||
| 137 | |||
| 138 | // PTY output | ||
| 139 | if (pollfds[1].revents & std.posix.POLL.IN != 0) { | ||
| 140 | while (true) { | ||
| 141 | const n = p.read(&read_buf) catch |err| switch (err) { | ||
| 142 | error.WouldBlock => break, | ||
| 143 | error.InputOutput => break, // child likely exited | ||
| 144 | else => return err, | ||
| 145 | }; | ||
| 146 | if (n == 0) break; | ||
| 147 | term.write(read_buf[0..n]); | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | // Keyboard events → write to pty | ||
| 152 | keyboard.tickRepeat(); | ||
| 153 | for (keyboard.event_queue.items) |ev| { | ||
| 154 | if (ev.action == .release) continue; | ||
| 155 | if (ev.utf8_len > 0) { | ||
| 156 | _ = try p.write(ev.utf8[0..ev.utf8_len]); | ||
| 157 | } | ||
| 158 | // Keys without UTF-8 (arrows, function keys) are skipped for v1 | ||
| 159 | } | ||
| 160 | keyboard.event_queue.clearRetainingCapacity(); | ||
| 161 | |||
| 162 | // === render === | ||
| 163 | try term.snapshot(); | ||
| 164 | |||
| 165 | instances.clearRetainingCapacity(); | ||
| 166 | |||
| 167 | const term_rows = term.render_state.row_data.items(.cells); | ||
| 168 | var row_idx: u32 = 0; | ||
| 169 | while (row_idx < term_rows.len) : (row_idx += 1) { | ||
| 170 | const raw_cells = term_rows[row_idx].items(.raw); | ||
| 171 | var col_idx: u32 = 0; | ||
| 172 | while (col_idx < raw_cells.len) : (col_idx += 1) { | ||
| 173 | const cp = raw_cells[col_idx].codepoint(); | ||
| 174 | if (cp == 0 or cp == ' ') continue; | ||
| 175 | |||
| 176 | const uv = atlas.getOrInsert(&face, @intCast(cp)) catch continue; | ||
| 177 | |||
| 178 | try instances.append(alloc, .{ | ||
| 179 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 180 | .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) }, | ||
| 181 | .glyph_bearing = .{ | ||
| 182 | @floatFromInt(uv.bearing_x), | ||
| 183 | // bearing_y in freetype is from baseline going up; our quad origin is top-left going down | ||
| 184 | // cell_h - bearing_y gives the offset from the top of the cell | ||
| 185 | @as(f32, @floatFromInt(cell_h)) - @as(f32, @floatFromInt(uv.bearing_y)), | ||
| 186 | }, | ||
| 187 | .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 }, | ||
| 188 | .fg = .{ 0.9, 0.9, 0.9, 1.0 }, | ||
| 189 | .bg = .{ 0.08, 0.08, 0.08, 1.0 }, | ||
| 190 | }); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | // Re-upload atlas if new glyphs were added | ||
| 195 | if (atlas.dirty) { | ||
| 196 | try ctx.uploadAtlas(atlas.pixels); | ||
| 197 | atlas.dirty = false; | ||
| 198 | } | ||
| 199 | |||
| 200 | if (instances.items.len > 0) { | ||
| 201 | try ctx.uploadInstances(instances.items); | ||
| 202 | } | ||
| 203 | |||
| 204 | try ctx.drawCells( | ||
| 205 | @intCast(instances.items.len), | ||
| 206 | .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | |||
| 210 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | ||
| 37 | } | 211 | } |
| 38 | 212 | ||
| 39 | fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { | 213 | fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { |
| @@ -88,6 +262,11 @@ fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { | |||
| 88 | const instances = [_]renderer.Instance{ | 262 | const instances = [_]renderer.Instance{ |
| 89 | .{ | 263 | .{ |
| 90 | .cell_pos = .{ 40.0, 12.0 }, | 264 | .cell_pos = .{ 40.0, 12.0 }, |
| 265 | .glyph_size = .{ @floatFromInt(glyph_uv.width), @floatFromInt(glyph_uv.height) }, | ||
| 266 | .glyph_bearing = .{ | ||
| 267 | @floatFromInt(glyph_uv.bearing_x), | ||
| 268 | @as(f32, @floatFromInt(face.cellHeight())) - @as(f32, @floatFromInt(glyph_uv.bearing_y)), | ||
| 269 | }, | ||
| 91 | .uv_rect = .{ glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1 }, | 270 | .uv_rect = .{ glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1 }, |
| 92 | .fg = .{ 1.0, 1.0, 1.0, 1.0 }, | 271 | .fg = .{ 1.0, 1.0, 1.0, 1.0 }, |
| 93 | .bg = .{ 0.0, 0.0, 0.0, 1.0 }, | 272 | .bg = .{ 0.0, 0.0, 0.0, 1.0 }, |
src/renderer.zig
| Old | New | ||
|---|---|---|---|
| @@ -211,10 +211,12 @@ pub const Vertex = extern struct { | |||
| 211 | 211 | ||
| 212 | /// Per-instance data (binding 1, per-instance rate) | 212 | /// Per-instance data (binding 1, per-instance rate) |
| 213 | pub const Instance = extern struct { | 213 | pub const Instance = extern struct { |
| 214 | cell_pos: [2]f32, // location 1 | 214 | cell_pos: [2]f32, // location 1 |
| 215 | uv_rect: [4]f32, // location 2 | 215 | glyph_size: [2]f32, // location 2 |
| 216 | fg: [4]f32, // location 3 | 216 | glyph_bearing: [2]f32, // location 3 |
| 217 | bg: [4]f32, // location 4 | 217 | uv_rect: [4]f32, // location 4 |
| 218 | fg: [4]f32, // location 5 | ||
| 219 | bg: [4]f32, // location 6 | ||
| 218 | }; | 220 | }; |
| 219 | 221 | ||
| 220 | const BufferResult = struct { | 222 | const BufferResult = struct { |
| @@ -533,9 +535,11 @@ pub const Context = struct { | |||
| 533 | const attr_descs = [_]vk.VertexInputAttributeDescription{ | 535 | const attr_descs = [_]vk.VertexInputAttributeDescription{ |
| 534 | .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 }, | 536 | .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 }, |
| 535 | .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") }, | 537 | .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") }, |
| 536 | .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") }, | 538 | .{ .location = 2, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "glyph_size") }, |
| 537 | .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") }, | 539 | .{ .location = 3, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "glyph_bearing") }, |
| 538 | .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") }, | 540 | .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") }, |
| 541 | .{ .location = 5, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") }, | ||
| 542 | .{ .location = 6, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") }, | ||
| 539 | }; | 543 | }; |
| 540 | 544 | ||
| 541 | const vertex_input_info = vk.PipelineVertexInputStateCreateInfo{ | 545 | const vertex_input_info = vk.PipelineVertexInputStateCreateInfo{ |
| @@ -1085,7 +1089,7 @@ pub const Context = struct { | |||
| 1085 | }); | 1089 | }); |
| 1086 | 1090 | ||
| 1087 | const clear_value = vk.ClearValue{ | 1091 | const clear_value = vk.ClearValue{ |
| 1088 | .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } }, | 1092 | .color = .{ .float_32 = .{ 0.08, 0.08, 0.08, 1.0 } }, |
| 1089 | }; | 1093 | }; |
| 1090 | 1094 | ||
| 1091 | self.vkd.cmdBeginRenderPass(self.command_buffer, &vk.RenderPassBeginInfo{ | 1095 | self.vkd.cmdBeginRenderPass(self.command_buffer, &vk.RenderPassBeginInfo{ |