a73x

47eefde2

feat: full terminal integration — waystty works

a73x   2026-04-08 09:33

Commit message
feat: full terminal integration — waystty works

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>

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{