a73x

c05013f9

Add waystty implementation plan

a73x   2026-04-08 05:30

Commit message
Add waystty implementation plan

Monolithic plan covering all seven phases: scaffolding, PTY, VT wrapper,
headless proof-of-life, font rendering, Wayland protocol, Vulkan
renderer, and full integration. TDD with bite-sized tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-07-waystty-implementation.md
Old New
@@ -0,0 +1,4288 @@
1 # waystty Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Build waystty — a minimal, hackable Wayland terminal emulator in Zig — using libghostty-vt for terminal emulation, Vulkan for rendering, and freetype for glyph rasterization.
6
7 **Architecture:** Six modules (`main`, `wayland`, `vt`, `pty`, `font`, `renderer`). Single-threaded `poll()` event loop multiplexing Wayland and PTY file descriptors. Vulkan renders the terminal grid via a single instanced draw call per frame, sampling a glyph atlas texture.
8
9 **Tech Stack:** Zig 0.15, libghostty-vt (Zig module), zig-wayland, vulkan-zig, freetype2, fontconfig, xkbcommon, wayland-client, glslc.
10
11 ## Important Deviation From Spec
12
13 The spec describes wrapping libghostty-vt via `@cImport <ghostty/vt.h>`. During planning research we discovered that **ghostty-vt is published as a native Zig module** with an idiomatic Zig API. We use the Zig module directly — no `@cImport` needed. This simplifies `vt.zig` significantly.
14
15 The reference for the Zig module API is `example/zig-vt/` in the ghostty repo. Engineers executing this plan should consult that example when the API shape isn't clear from this plan.
16
17 ## Reference Files
18
19 When in doubt, consult these external references:
20
21 - **ghostty-vt Zig API**: `ghostty-org/ghostty:example/zig-vt/src/main.zig` and `build.zig`
22 - **ghostty-vt C headers** (for semantics reference): `ghostty-org/ghostty:include/ghostty/vt.h`
23 - **Ghostling C reference**: `ghostty-org/ghostling:main.c` — full end-to-end terminal
24 - **zig-wayland example**: `ifreund/zig-wayland:example/hello/hello.zig`
25 - **vulkan-zig examples**: `Snektron/vulkan-zig:examples/`
26
27 ---
28
29 ## Phase 0: Project Scaffolding
30
31 ### Task 0.1: Initialize project and git
32
33 **Files:**
34 - Create: `.gitignore`
35 - Already exists: `docs/superpowers/specs/2026-04-07-waystty-design.md`
36 - Already exists: `.git/`
37
38 - [ ] **Step 1: Create .gitignore**
39
40 ```
41 zig-out/
42 .zig-cache/
43 *.o
44 *.swp
45 ```
46
47 - [ ] **Step 2: Commit**
48
49 ```bash
50 git add .gitignore
51 git commit -m "chore: add gitignore"
52 ```
53
54 ---
55
56 ### Task 0.2: Create build.zig.zon with dependencies
57
58 **Files:**
59 - Create: `build.zig.zon`
60
61 - [ ] **Step 1: Write build.zig.zon**
62
63 ```zig
64 .{
65 .name = .waystty,
66 .version = "0.0.1",
67 .fingerprint = 0x1234567890abcdef,
68 .minimum_zig_version = "0.15.0",
69 .paths = .{
70 "build.zig",
71 "build.zig.zon",
72 "src",
73 "shaders",
74 },
75 .dependencies = .{
76 .wayland = .{
77 .url = "git+https://github.com/ifreund/zig-wayland",
78 .hash = "",
79 },
80 .vulkan = .{
81 .url = "git+https://github.com/Snektron/vulkan-zig",
82 .hash = "",
83 },
84 .vulkan_headers = .{
85 .url = "git+https://github.com/KhronosGroup/Vulkan-Headers",
86 .hash = "",
87 },
88 .ghostty = .{
89 .url = "git+https://github.com/ghostty-org/ghostty",
90 .hash = "",
91 .lazy = true,
92 },
93 },
94 }
95 ```
96
97 - [ ] **Step 2: Fetch dependencies to populate hashes**
98
99 ```bash
100 zig fetch --save git+https://github.com/ifreund/zig-wayland
101 zig fetch --save git+https://github.com/Snektron/vulkan-zig
102 zig fetch --save git+https://github.com/KhronosGroup/Vulkan-Headers
103 zig fetch --save git+https://github.com/ghostty-org/ghostty
104 ```
105
106 Expected: each command updates `build.zig.zon` with the correct hash.
107
108 - [ ] **Step 3: Generate fingerprint**
109
110 Edit `build.zig.zon` and change the `.fingerprint` value to a unique `u64`. Zig will print an error with the expected value when you first try to build — use that.
111
112 - [ ] **Step 4: Commit**
113
114 ```bash
115 git add build.zig.zon
116 git commit -m "chore: add build.zig.zon with dependencies"
117 ```
118
119 ---
120
121 ### Task 0.3: Create minimal build.zig
122
123 **Files:**
124 - Create: `build.zig`
125 - Create: `src/main.zig`
126
127 - [ ] **Step 1: Write minimal src/main.zig**
128
129 ```zig
130 const std = @import("std");
131
132 pub fn main() !void {
133 std.debug.print("waystty\n", .{});
134 }
135 ```
136
137 - [ ] **Step 2: Write initial build.zig (executable only, deps added later)**
138
139 ```zig
140 const std = @import("std");
141
142 pub fn build(b: *std.Build) void {
143 const target = b.standardTargetOptions(.{});
144 const optimize = b.standardOptimizeOption(.{});
145
146 const exe = b.addExecutable(.{
147 .name = "waystty",
148 .root_source_file = b.path("src/main.zig"),
149 .target = target,
150 .optimize = optimize,
151 });
152
153 b.installArtifact(exe);
154
155 const run_cmd = b.addRunArtifact(exe);
156 run_cmd.step.dependOn(b.getInstallStep());
157 if (b.args) |args| run_cmd.addArgs(args);
158
159 const run_step = b.step("run", "Run waystty");
160 run_step.dependOn(&run_cmd.step);
161
162 const test_step = b.step("test", "Run unit tests");
163 const tests = b.addTest(.{
164 .root_source_file = b.path("src/main.zig"),
165 .target = target,
166 .optimize = optimize,
167 });
168 test_step.dependOn(&b.addRunArtifact(tests).step);
169 }
170 ```
171
172 - [ ] **Step 3: Build and run**
173
174 ```bash
175 zig build run
176 ```
177
178 Expected: prints `waystty`.
179
180 - [ ] **Step 4: Commit**
181
182 ```bash
183 git add build.zig src/main.zig
184 git commit -m "chore: minimal build.zig and main.zig"
185 ```
186
187 ---
188
189 ### Task 0.4: Add ghostty-vt Zig module to build.zig
190
191 **Files:**
192 - Modify: `build.zig`
193
194 - [ ] **Step 1: Create src/vt.zig stub that imports ghostty-vt**
195
196 ```zig
197 const std = @import("std");
198 const ghostty_vt = @import("ghostty-vt");
199
200 test "ghostty-vt module imports" {
201 // Smoke test — just reference the module
202 _ = ghostty_vt;
203 }
204 ```
205
206 - [ ] **Step 2: Modify build.zig to expose the ghostty-vt module**
207
208 Add after `const exe = b.addExecutable(...)` and before `b.installArtifact(exe);`:
209
210 ```zig
211 if (b.lazyDependency("ghostty", .{
212 .target = target,
213 .optimize = optimize,
214 })) |ghostty_dep| {
215 exe.root_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
216 }
217 ```
218
219 Also add the same for the test step, and add `src/vt.zig` as a module that `main.zig` can import:
220
221 ```zig
222 const vt_module = b.addModule("vt", .{
223 .root_source_file = b.path("src/vt.zig"),
224 .target = target,
225 .optimize = optimize,
226 });
227 if (b.lazyDependency("ghostty", .{
228 .target = target,
229 .optimize = optimize,
230 })) |ghostty_dep| {
231 vt_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
232 }
233 exe.root_module.addImport("vt", vt_module);
234 ```
235
236 - [ ] **Step 3: Update main.zig to import vt**
237
238 ```zig
239 const std = @import("std");
240 const vt = @import("vt");
241
242 pub fn main() !void {
243 std.debug.print("waystty\n", .{});
244 _ = vt;
245 }
246 ```
247
248 - [ ] **Step 4: Build**
249
250 ```bash
251 zig build
252 ```
253
254 Expected: builds successfully. First build will be slow as it compiles ghostty-vt.
255
256 - [ ] **Step 5: Commit**
257
258 ```bash
259 git add build.zig src/main.zig src/vt.zig
260 git commit -m "build: wire ghostty-vt module"
261 ```
262
263 ---
264
265 ## Phase 1: PTY Module
266
267 ### Task 1.1: Create pty.zig with forkpty wrapper — failing test first
268
269 **Files:**
270 - Create: `src/pty.zig`
271 - Modify: `build.zig` (add pty module and test step)
272
273 - [ ] **Step 1: Write failing test for Pty.spawn**
274
275 Create `src/pty.zig`:
276
277 ```zig
278 const std = @import("std");
279 const c = @cImport({
280 @cInclude("pty.h");
281 @cInclude("termios.h");
282 @cInclude("unistd.h");
283 @cInclude("sys/ioctl.h");
284 @cInclude("fcntl.h");
285 @cInclude("sys/wait.h");
286 });
287
288 pub const Pty = struct {
289 master_fd: std.posix.fd_t,
290 child_pid: std.posix.pid_t,
291
292 pub const SpawnOptions = struct {
293 cols: u16,
294 rows: u16,
295 shell: []const u8,
296 };
297
298 pub fn spawn(opts: SpawnOptions) !Pty {
299 _ = opts;
300 return error.NotImplemented;
301 }
302
303 pub fn deinit(self: *Pty) void {
304 _ = self;
305 }
306 };
307
308 test "Pty.spawn launches /bin/sh and returns valid fd" {
309 var pty = try Pty.spawn(.{
310 .cols = 80,
311 .rows = 24,
312 .shell = "/bin/sh",
313 });
314 defer pty.deinit();
315 try std.testing.expect(pty.master_fd >= 0);
316 try std.testing.expect(pty.child_pid > 0);
317 }
318 ```
319
320 - [ ] **Step 2: Register pty module and test step in build.zig**
321
322 Add to `build.zig` (alongside the `vt_module` section):
323
324 ```zig
325 const pty_module = b.addModule("pty", .{
326 .root_source_file = b.path("src/pty.zig"),
327 .target = target,
328 .optimize = optimize,
329 });
330 pty_module.link_libc = true;
331 exe.root_module.addImport("pty", pty_module);
332 ```
333
334 And extend the test step to also run pty tests:
335
336 ```zig
337 const pty_tests = b.addTest(.{
338 .root_source_file = b.path("src/pty.zig"),
339 .target = target,
340 .optimize = optimize,
341 });
342 pty_tests.linkLibC();
343 pty_tests.linkSystemLibrary("util"); // for forkpty
344 test_step.dependOn(&b.addRunArtifact(pty_tests).step);
345 ```
346
347 - [ ] **Step 3: Run the failing test**
348
349 ```bash
350 zig build test
351 ```
352
353 Expected: FAIL with `error.NotImplemented`.
354
355 - [ ] **Step 4: Implement Pty.spawn using forkpty**
356
357 Replace the body of `spawn`:
358
359 ```zig
360 pub fn spawn(opts: SpawnOptions) !Pty {
361 var master: c_int = undefined;
362 var winsize = c.struct_winsize{
363 .ws_row = opts.rows,
364 .ws_col = opts.cols,
365 .ws_xpixel = 0,
366 .ws_ypixel = 0,
367 };
368
369 const pid = c.forkpty(&master, null, null, &winsize);
370 if (pid < 0) return error.ForkptyFailed;
371
372 if (pid == 0) {
373 // Child process
374 _ = c.setenv("TERM", "xterm-256color", 1);
375
376 const shell_z = std.heap.page_allocator.dupeZ(u8, opts.shell) catch std.process.exit(1);
377 const argv = [_:null]?[*:0]const u8{ shell_z.ptr, null };
378 const envp: [*:null]?[*:0]const u8 = @ptrCast(std.c.environ);
379 _ = std.c.execve(shell_z.ptr, &argv, envp);
380 std.process.exit(1);
381 }
382
383 // Parent: set master fd non-blocking
384 const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
385 _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })));
386
387 return .{
388 .master_fd = master,
389 .child_pid = pid,
390 };
391 }
392
393 pub fn deinit(self: *Pty) void {
394 std.posix.close(self.master_fd);
395 _ = std.c.kill(self.child_pid, std.c.SIG.TERM);
396 _ = std.c.waitpid(self.child_pid, null, 0);
397 }
398 ```
399
400 - [ ] **Step 5: Run test**
401
402 ```bash
403 zig build test
404 ```
405
406 Expected: PASS.
407
408 - [ ] **Step 6: Commit**
409
410 ```bash
411 git add src/pty.zig build.zig
412 git commit -m "feat(pty): spawn child shell via forkpty"
413 ```
414
415 ---
416
417 ### Task 1.2: Add Pty.read/write helpers
418
419 **Files:**
420 - Modify: `src/pty.zig`
421
422 - [ ] **Step 1: Write failing test**
423
424 Add to `src/pty.zig`:
425
426 ```zig
427 test "Pty.write and read echoes through shell" {
428 var pty = try Pty.spawn(.{
429 .cols = 80,
430 .rows = 24,
431 .shell = "/bin/sh",
432 });
433 defer pty.deinit();
434
435 // Give shell a moment to start
436 std.Thread.sleep(100 * std.time.ns_per_ms);
437
438 // Write a command
439 _ = try pty.write("echo hello\n");
440
441 // Drain output for up to 1 second
442 var buf: [4096]u8 = undefined;
443 var seen_hello = false;
444 const deadline = std.time.nanoTimestamp() + 1 * std.time.ns_per_s;
445 while (std.time.nanoTimestamp() < deadline) {
446 const n = pty.read(&buf) catch |err| switch (err) {
447 error.WouldBlock => {
448 std.Thread.sleep(10 * std.time.ns_per_ms);
449 continue;
450 },
451 else => return err,
452 };
453 if (std.mem.indexOf(u8, buf[0..n], "hello") != null) {
454 seen_hello = true;
455 break;
456 }
457 }
458 try std.testing.expect(seen_hello);
459 }
460 ```
461
462 - [ ] **Step 2: Run failing test**
463
464 ```bash
465 zig build test
466 ```
467
468 Expected: FAIL (read/write don't exist yet).
469
470 - [ ] **Step 3: Implement read and write**
471
472 Add methods to `Pty`:
473
474 ```zig
475 pub fn read(self: *Pty, buf: []u8) !usize {
476 return std.posix.read(self.master_fd, buf) catch |err| switch (err) {
477 error.WouldBlock => error.WouldBlock,
478 else => err,
479 };
480 }
481
482 pub fn write(self: *Pty, data: []const u8) !usize {
483 return std.posix.write(self.master_fd, data);
484 }
485 ```
486
487 - [ ] **Step 4: Run test**
488
489 ```bash
490 zig build test
491 ```
492
493 Expected: PASS.
494
495 - [ ] **Step 5: Commit**
496
497 ```bash
498 git add src/pty.zig
499 git commit -m "feat(pty): add read/write helpers"
500 ```
501
502 ---
503
504 ### Task 1.3: Add Pty.resize
505
506 **Files:**
507 - Modify: `src/pty.zig`
508
509 - [ ] **Step 1: Write failing test**
510
511 Add to `src/pty.zig`:
512
513 ```zig
514 test "Pty.resize sets winsize via ioctl" {
515 var pty = try Pty.spawn(.{
516 .cols = 80,
517 .rows = 24,
518 .shell = "/bin/sh",
519 });
520 defer pty.deinit();
521
522 try pty.resize(120, 40);
523
524 var ws: c.struct_winsize = undefined;
525 const rc = c.ioctl(pty.master_fd, c.TIOCGWINSZ, &ws);
526 try std.testing.expectEqual(@as(c_int, 0), rc);
527 try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col);
528 try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row);
529 }
530 ```
531
532 - [ ] **Step 2: Run failing test**
533
534 ```bash
535 zig build test
536 ```
537
538 Expected: FAIL (resize doesn't exist).
539
540 - [ ] **Step 3: Implement resize**
541
542 ```zig
543 pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
544 var ws = c.struct_winsize{
545 .ws_row = rows,
546 .ws_col = cols,
547 .ws_xpixel = 0,
548 .ws_ypixel = 0,
549 };
550 if (c.ioctl(self.master_fd, c.TIOCSWINSZ, &ws) < 0) {
551 return error.IoctlFailed;
552 }
553 }
554 ```
555
556 - [ ] **Step 4: Run test**
557
558 ```bash
559 zig build test
560 ```
561
562 Expected: PASS.
563
564 - [ ] **Step 5: Commit**
565
566 ```bash
567 git add src/pty.zig
568 git commit -m "feat(pty): add resize"
569 ```
570
571 ---
572
573 ## Phase 2: VT Module (libghostty-vt wrapper)
574
575 **⚠️ Implementer note:** The exact ghostty-vt Zig API must be discovered from the upstream example. Before writing Task 2.x steps, read `ghostty-org/ghostty:example/zig-vt/src/main.zig` to confirm the exact type and function names. The code below uses plausible names that match the C API semantics — adjust to match the real Zig API.
576
577 ### Task 2.1: Discover the ghostty-vt Zig API
578
579 **Files:**
580 - Modify: `src/vt.zig` (documentation comments)
581
582 - [ ] **Step 1: Fetch the upstream example**
583
584 ```bash
585 cd .zig-cache/p && find . -name "main.zig" -path "*example/zig-vt*" | head -1
586 ```
587
588 Expected: prints a path inside the fetched ghostty dependency.
589
590 - [ ] **Step 2: Read the example and take notes in src/vt.zig**
591
592 Read the `main.zig` and `build.zig` of `example/zig-vt`. At the top of `src/vt.zig`, write a doc comment listing the actual types and methods you found:
593
594 ```zig
595 //! vt.zig — wrapper around the ghostty-vt Zig module.
596 //!
597 //! Upstream API observed from example/zig-vt/src/main.zig:
598 //! const ghostty_vt = @import("ghostty-vt");
599 //! ghostty_vt.Terminal — init/deinit, write(bytes), resize(cols, rows)
600 //! ghostty_vt.KeyEncoder — init, syncFromTerminal, encode
601 //! ghostty_vt.MouseEncoder — init, syncFromTerminal, encode
602 //! ghostty_vt.RenderState — init, update, rowIterator
603 //!
604 //! The implementer should update these notes with the exact type and
605 //! method names found in the pinned version of ghostty-vt before
606 //! continuing to Task 2.2. If upstream names differ, tasks 2.2-2.7
607 //! must be adjusted to match.
608 ```
609
610 - [ ] **Step 3: Commit notes**
611
612 ```bash
613 git add src/vt.zig
614 git commit -m "docs(vt): note upstream ghostty-vt API shape"
615 ```
616
617 ---
618
619 ### Task 2.2: Wrap Terminal lifecycle
620
621 **Files:**
622 - Modify: `src/vt.zig`
623
624 - [ ] **Step 1: Write failing test**
625
626 Add to `src/vt.zig`:
627
628 ```zig
629 test "Terminal init/deinit" {
630 var term = try Terminal.init(std.testing.allocator, .{
631 .cols = 80,
632 .rows = 24,
633 .max_scrollback = 1000,
634 });
635 defer term.deinit();
636 try std.testing.expectEqual(@as(u16, 80), term.cols);
637 try std.testing.expectEqual(@as(u16, 24), term.rows);
638 }
639 ```
640
641 - [ ] **Step 2: Register vt tests in build.zig**
642
643 Add a `vt_tests` block alongside `pty_tests` in `build.zig`:
644
645 ```zig
646 const vt_tests = b.addTest(.{
647 .root_source_file = b.path("src/vt.zig"),
648 .target = target,
649 .optimize = optimize,
650 });
651 if (b.lazyDependency("ghostty", .{
652 .target = target,
653 .optimize = optimize,
654 })) |ghostty_dep| {
655 vt_tests.root_module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt"));
656 }
657 test_step.dependOn(&b.addRunArtifact(vt_tests).step);
658 ```
659
660 - [ ] **Step 3: Run failing test**
661
662 ```bash
663 zig build test
664 ```
665
666 Expected: FAIL (Terminal not defined).
667
668 - [ ] **Step 4: Implement Terminal.init/deinit**
669
670 Replace the body of `src/vt.zig`:
671
672 ```zig
673 const std = @import("std");
674 const ghostty_vt = @import("ghostty-vt");
675
676 pub const InitOptions = struct {
677 cols: u16,
678 rows: u16,
679 max_scrollback: u32 = 1000,
680 };
681
682 pub const Terminal = struct {
683 inner: ghostty_vt.Terminal,
684 cols: u16,
685 rows: u16,
686
687 pub fn init(allocator: std.mem.Allocator, opts: InitOptions) !Terminal {
688 const inner = try ghostty_vt.Terminal.init(allocator, .{
689 .cols = opts.cols,
690 .rows = opts.rows,
691 .max_scrollback = opts.max_scrollback,
692 });
693 return .{
694 .inner = inner,
695 .cols = opts.cols,
696 .rows = opts.rows,
697 };
698 }
699
700 pub fn deinit(self: *Terminal) void {
701 self.inner.deinit();
702 }
703 };
704 ```
705
706 **If the upstream Terminal.init signature differs from this** (e.g. it returns a pointer, or the options struct has different fields), adjust to match the real signature from `example/zig-vt`.
707
708 - [ ] **Step 5: Run test**
709
710 ```bash
711 zig build test
712 ```
713
714 Expected: PASS.
715
716 - [ ] **Step 6: Commit**
717
718 ```bash
719 git add src/vt.zig build.zig
720 git commit -m "feat(vt): Terminal init/deinit wrapper"
721 ```
722
723 ---
724
725 ### Task 2.3: Terminal.write (feed bytes to VT parser)
726
727 **Files:**
728 - Modify: `src/vt.zig`
729
730 - [ ] **Step 1: Write failing test**
731
732 Add to `src/vt.zig`:
733
734 ```zig
735 test "Terminal.write feeds bytes to VT parser" {
736 var term = try Terminal.init(std.testing.allocator, .{
737 .cols = 80,
738 .rows = 24,
739 });
740 defer term.deinit();
741
742 try term.write("Hello");
743 // We can't easily assert on internal state here yet — that's Task 2.4.
744 // Just verify the call succeeds.
745 }
746 ```
747
748 - [ ] **Step 2: Run failing test**
749
750 ```bash
751 zig build test
752 ```
753
754 Expected: FAIL (`write` not defined on Terminal).
755
756 - [ ] **Step 3: Implement Terminal.write**
757
758 Add to `Terminal`:
759
760 ```zig
761 pub fn write(self: *Terminal, bytes: []const u8) !void {
762 try self.inner.write(bytes);
763 }
764 ```
765
766 Adjust to match the actual upstream method name if different (e.g. `vtWrite`, `feed`, etc.).
767
768 - [ ] **Step 4: Run test**
769
770 ```bash
771 zig build test
772 ```
773
774 Expected: PASS.
775
776 - [ ] **Step 5: Commit**
777
778 ```bash
779 git add src/vt.zig
780 git commit -m "feat(vt): Terminal.write"
781 ```
782
783 ---
784
785 ### Task 2.4: Render state snapshot and row iteration
786
787 **Files:**
788 - Modify: `src/vt.zig`
789
790 - [ ] **Step 1: Write failing test**
791
792 Add to `src/vt.zig`:
793
794 ```zig
795 test "RenderState iterates cells after write" {
796 var term = try Terminal.init(std.testing.allocator, .{
797 .cols = 80,
798 .rows = 24,
799 });
800 defer term.deinit();
801
802 try term.write("Hi");
803
804 var render_state = try RenderState.init(std.testing.allocator);
805 defer render_state.deinit();
806
807 try render_state.update(&term);
808
809 // First row should contain 'H' at col 0, 'i' at col 1.
810 var row_iter = try render_state.rowIterator();
811 defer row_iter.deinit();
812
813 const first_row = (try row_iter.next()) orelse return error.NoRow;
814 defer first_row.deinit();
815
816 var cells = first_row.cells();
817 defer cells.deinit();
818
819 const cell0 = (try cells.next()) orelse return error.NoCell;
820 try std.testing.expectEqual(@as(u21, 'H'), cell0.codepoint);
821
822 const cell1 = (try cells.next()) orelse return error.NoCell;
823 try std.testing.expectEqual(@as(u21, 'i'), cell1.codepoint);
824 }
825 ```
826
827 - [ ] **Step 2: Run failing test**
828
829 ```bash
830 zig build test
831 ```
832
833 Expected: FAIL (RenderState etc. not defined).
834
835 - [ ] **Step 3: Implement RenderState wrapper**
836
837 Add to `src/vt.zig`:
838
839 ```zig
840 pub const Cell = struct {
841 codepoint: u21,
842 fg: Rgb,
843 bg: Rgb,
844 bold: bool,
845 italic: bool,
846 inverse: bool,
847 underline: bool,
848 };
849
850 pub const Rgb = struct { r: u8, g: u8, b: u8 };
851
852 pub const RenderState = struct {
853 inner: ghostty_vt.RenderState,
854
855 pub fn init(allocator: std.mem.Allocator) !RenderState {
856 return .{ .inner = try ghostty_vt.RenderState.init(allocator) };
857 }
858
859 pub fn deinit(self: *RenderState) void {
860 self.inner.deinit();
861 }
862
863 pub fn update(self: *RenderState, term: *Terminal) !void {
864 try self.inner.update(&term.inner);
865 }
866
867 pub fn rowIterator(self: *RenderState) !RowIterator {
868 return .{ .inner = try self.inner.rowIterator() };
869 }
870 };
871
872 pub const RowIterator = struct {
873 inner: ghostty_vt.RenderState.RowIterator,
874
875 pub fn deinit(self: *RowIterator) void {
876 self.inner.deinit();
877 }
878
879 pub fn next(self: *RowIterator) !?Row {
880 const row = (try self.inner.next()) orelse return null;
881 return .{ .inner = row };
882 }
883 };
884
885 pub const Row = struct {
886 inner: ghostty_vt.RenderState.Row,
887
888 pub fn deinit(self: *Row) void {
889 self.inner.deinit();
890 }
891
892 pub fn cells(self: *Row) CellIterator {
893 return .{ .inner = self.inner.cells() };
894 }
895 };
896
897 pub const CellIterator = struct {
898 inner: ghostty_vt.RenderState.CellIterator,
899
900 pub fn deinit(self: *CellIterator) void {
901 self.inner.deinit();
902 }
903
904 pub fn next(self: *CellIterator) !?Cell {
905 const c = (try self.inner.next()) orelse return null;
906 return .{
907 .codepoint = c.codepoint,
908 .fg = .{ .r = c.fg.r, .g = c.fg.g, .b = c.fg.b },
909 .bg = .{ .r = c.bg.r, .g = c.bg.g, .b = c.bg.b },
910 .bold = c.bold,
911 .italic = c.italic,
912 .inverse = c.inverse,
913 .underline = c.underline,
914 };
915 }
916 };
917 ```
918
919 **⚠️ This code assumes an API shape. Adjust every field name and method to match what's in `example/zig-vt/src/main.zig`.** The structure (init/deinit/iterator-of-iterator) is correct based on the C API; the exact Zig names may differ.
920
921 - [ ] **Step 4: Run test**
922
923 ```bash
924 zig build test
925 ```
926
927 Expected: PASS (after adjusting to real API).
928
929 - [ ] **Step 5: Commit**
930
931 ```bash
932 git add src/vt.zig
933 git commit -m "feat(vt): RenderState with row/cell iteration"
934 ```
935
936 ---
937
938 ### Task 2.5: Key encoder
939
940 **Files:**
941 - Modify: `src/vt.zig`
942
943 - [ ] **Step 1: Write failing test**
944
945 Add to `src/vt.zig`:
946
947 ```zig
948 test "KeyEncoder encodes a keystroke" {
949 var term = try Terminal.init(std.testing.allocator, .{
950 .cols = 80,
951 .rows = 24,
952 });
953 defer term.deinit();
954
955 var encoder = try KeyEncoder.init(std.testing.allocator);
956 defer encoder.deinit();
957
958 try encoder.syncFromTerminal(&term);
959
960 var buf: [64]u8 = undefined;
961 const n = try encoder.encode(&buf, .{
962 .keysym = 'a',
963 .modifiers = .{},
964 .action = .press,
965 });
966 try std.testing.expect(n >= 1);
967 try std.testing.expectEqual(@as(u8, 'a'), buf[0]);
968 }
969 ```
970
971 - [ ] **Step 2: Run failing test**
972
973 ```bash
974 zig build test
975 ```
976
977 Expected: FAIL.
978
979 - [ ] **Step 3: Implement KeyEncoder**
980
981 Add to `src/vt.zig`:
982
983 ```zig
984 pub const Modifiers = struct {
985 ctrl: bool = false,
986 shift: bool = false,
987 alt: bool = false,
988 super: bool = false,
989 };
990
991 pub const KeyAction = enum { press, release, repeat };
992
993 pub const KeyEvent = struct {
994 keysym: u32,
995 modifiers: Modifiers,
996 action: KeyAction,
997 };
998
999 pub const KeyEncoder = struct {
1000 inner: ghostty_vt.KeyEncoder,
1001
1002 pub fn init(allocator: std.mem.Allocator) !KeyEncoder {
1003 return .{ .inner = try ghostty_vt.KeyEncoder.init(allocator) };
1004 }
1005
1006 pub fn deinit(self: *KeyEncoder) void {
1007 self.inner.deinit();
1008 }
1009
1010 pub fn syncFromTerminal(self: *KeyEncoder, term: *Terminal) !void {
1011 try self.inner.syncFromTerminal(&term.inner);
1012 }
1013
1014 pub fn encode(self: *KeyEncoder, buf: []u8, ev: KeyEvent) !usize {
1015 const inner_ev = ghostty_vt.KeyEvent{
1016 .keysym = ev.keysym,
1017 .modifiers = .{
1018 .ctrl = ev.modifiers.ctrl,
1019 .shift = ev.modifiers.shift,
1020 .alt = ev.modifiers.alt,
1021 .super = ev.modifiers.super,
1022 },
1023 .action = switch (ev.action) {
1024 .press => .press,
1025 .release => .release,
1026 .repeat => .repeat,
1027 },
1028 };
1029 return try self.inner.encode(buf, inner_ev);
1030 }
1031 };
1032 ```
1033
1034 - [ ] **Step 4: Run test**
1035
1036 ```bash
1037 zig build test
1038 ```
1039
1040 Expected: PASS.
1041
1042 - [ ] **Step 5: Commit**
1043
1044 ```bash
1045 git add src/vt.zig
1046 git commit -m "feat(vt): KeyEncoder wrapper"
1047 ```
1048
1049 ---
1050
1051 ### Task 2.6: Mouse encoder
1052
1053 **Files:**
1054 - Modify: `src/vt.zig`
1055
1056 - [ ] **Step 1: Write failing test**
1057
1058 ```zig
1059 test "MouseEncoder encodes a click" {
1060 var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
1061 defer term.deinit();
1062
1063 var encoder = try MouseEncoder.init(std.testing.allocator);
1064 defer encoder.deinit();
1065
1066 try encoder.syncFromTerminal(&term);
1067
1068 var buf: [64]u8 = undefined;
1069 // With no mouse tracking mode enabled, encode should return 0
1070 const n = try encoder.encode(&buf, .{
1071 .x = 10,
1072 .y = 5,
1073 .button = .left,
1074 .action = .press,
1075 .modifiers = .{},
1076 });
1077 try std.testing.expectEqual(@as(usize, 0), n);
1078 }
1079 ```
1080
1081 - [ ] **Step 2: Run failing test**
1082
1083 ```bash
1084 zig build test
1085 ```
1086
1087 Expected: FAIL.
1088
1089 - [ ] **Step 3: Implement MouseEncoder**
1090
1091 ```zig
1092 pub const MouseButton = enum { left, middle, right, none };
1093
1094 pub const MouseAction = enum { press, release, motion, scroll_up, scroll_down };
1095
1096 pub const MouseEvent = struct {
1097 x: u16,
1098 y: u16,
1099 button: MouseButton,
1100 action: MouseAction,
1101 modifiers: Modifiers,
1102 };
1103
1104 pub const MouseEncoder = struct {
1105 inner: ghostty_vt.MouseEncoder,
1106
1107 pub fn init(allocator: std.mem.Allocator) !MouseEncoder {
1108 return .{ .inner = try ghostty_vt.MouseEncoder.init(allocator) };
1109 }
1110
1111 pub fn deinit(self: *MouseEncoder) void {
1112 self.inner.deinit();
1113 }
1114
1115 pub fn syncFromTerminal(self: *MouseEncoder, term: *Terminal) !void {
1116 try self.inner.syncFromTerminal(&term.inner);
1117 }
1118
1119 pub fn encode(self: *MouseEncoder, buf: []u8, ev: MouseEvent) !usize {
1120 const inner_ev = ghostty_vt.MouseEvent{
1121 .x = ev.x,
1122 .y = ev.y,
1123 .button = switch (ev.button) {
1124 .left => .left,
1125 .middle => .middle,
1126 .right => .right,
1127 .none => .none,
1128 },
1129 .action = switch (ev.action) {
1130 .press => .press,
1131 .release => .release,
1132 .motion => .motion,
1133 .scroll_up => .scroll_up,
1134 .scroll_down => .scroll_down,
1135 },
1136 .modifiers = .{
1137 .ctrl = ev.modifiers.ctrl,
1138 .shift = ev.modifiers.shift,
1139 .alt = ev.modifiers.alt,
1140 .super = ev.modifiers.super,
1141 },
1142 };
1143 return try self.inner.encode(buf, inner_ev);
1144 }
1145 };
1146 ```
1147
1148 - [ ] **Step 4: Run test**
1149
1150 ```bash
1151 zig build test
1152 ```
1153
1154 Expected: PASS.
1155
1156 - [ ] **Step 5: Commit**
1157
1158 ```bash
1159 git add src/vt.zig
1160 git commit -m "feat(vt): MouseEncoder wrapper"
1161 ```
1162
1163 ---
1164
1165 ### Task 2.7: Terminal.resize
1166
1167 **Files:**
1168 - Modify: `src/vt.zig`
1169
1170 - [ ] **Step 1: Write failing test**
1171
1172 ```zig
1173 test "Terminal.resize updates dimensions" {
1174 var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
1175 defer term.deinit();
1176
1177 try term.resize(120, 40);
1178 try std.testing.expectEqual(@as(u16, 120), term.cols);
1179 try std.testing.expectEqual(@as(u16, 40), term.rows);
1180 }
1181 ```
1182
1183 - [ ] **Step 2: Run failing test**
1184
1185 ```bash
1186 zig build test
1187 ```
1188
1189 Expected: FAIL.
1190
1191 - [ ] **Step 3: Implement Terminal.resize**
1192
1193 Add to `Terminal`:
1194
1195 ```zig
1196 pub fn resize(self: *Terminal, cols: u16, rows: u16) !void {
1197 try self.inner.resize(cols, rows);
1198 self.cols = cols;
1199 self.rows = rows;
1200 }
1201 ```
1202
1203 - [ ] **Step 4: Run test**
1204
1205 ```bash
1206 zig build test
1207 ```
1208
1209 Expected: PASS.
1210
1211 - [ ] **Step 5: Commit**
1212
1213 ```bash
1214 git add src/vt.zig
1215 git commit -m "feat(vt): Terminal.resize"
1216 ```
1217
1218 ---
1219
1220 ## Phase 3: Headless Integration — Proof Of Life
1221
1222 Before touching Wayland or Vulkan, we wire PTY + VT together into a headless tool that proves libghostty is working.
1223
1224 ### Task 3.1: Headless mode — dump grid to stdout
1225
1226 **Files:**
1227 - Modify: `src/main.zig`
1228
1229 - [ ] **Step 1: Write main loop that spawns shell, feeds output to terminal, dumps grid**
1230
1231 Replace `src/main.zig`:
1232
1233 ```zig
1234 const std = @import("std");
1235 const vt = @import("vt");
1236 const pty = @import("pty");
1237
1238 pub fn main() !void {
1239 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
1240 defer _ = gpa.deinit();
1241 const alloc = gpa.allocator();
1242
1243 const args = try std.process.argsAlloc(alloc);
1244 defer std.process.argsFree(alloc, args);
1245
1246 if (args.len >= 2 and std.mem.eql(u8, args[1], "--headless")) {
1247 return runHeadless(alloc);
1248 }
1249
1250 std.debug.print("waystty (run with --headless for CLI dump mode)\n", .{});
1251 }
1252
1253 fn runHeadless(alloc: std.mem.Allocator) !void {
1254 const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
1255
1256 var p = try pty.Pty.spawn(.{
1257 .cols = 80,
1258 .rows = 24,
1259 .shell = shell,
1260 });
1261 defer p.deinit();
1262
1263 var term = try vt.Terminal.init(alloc, .{
1264 .cols = 80,
1265 .rows = 24,
1266 });
1267 defer term.deinit();
1268
1269 // Run command: echo hello; exit
1270 _ = try p.write("echo hello; exit\n");
1271
1272 // Drain output
1273 var buf: [4096]u8 = undefined;
1274 const deadline = std.time.nanoTimestamp() + 2 * std.time.ns_per_s;
1275 while (std.time.nanoTimestamp() < deadline) {
1276 const n = p.read(&buf) catch |err| switch (err) {
1277 error.WouldBlock => {
1278 std.Thread.sleep(10 * std.time.ns_per_ms);
1279 continue;
1280 },
1281 else => return err,
1282 };
1283 if (n == 0) break;
1284 try term.write(buf[0..n]);
1285 }
1286
1287 // Dump the grid
1288 var render_state = try vt.RenderState.init(alloc);
1289 defer render_state.deinit();
1290
1291 try render_state.update(&term);
1292
1293 var row_iter = try render_state.rowIterator();
1294 defer row_iter.deinit();
1295
1296 const stdout = std.io.getStdOut().writer();
1297 while (try row_iter.next()) |row_| {
1298 var row = row_;
1299 defer row.deinit();
1300 var cells = row.cells();
1301 defer cells.deinit();
1302 while (try cells.next()) |cell| {
1303 var utf8: [4]u8 = undefined;
1304 const len = try std.unicode.utf8Encode(cell.codepoint, &utf8);
1305 try stdout.writeAll(utf8[0..len]);
1306 }
1307 try stdout.writeAll("\n");
1308 }
1309 }
1310 ```
1311
1312 - [ ] **Step 2: Build and run**
1313
1314 ```bash
1315 zig build run -- --headless
1316 ```
1317
1318 Expected: dumps a grid with `hello` visible in the output (and trailing spaces/empty rows). This proves PTY + VT are working together.
1319
1320 - [ ] **Step 3: Commit**
1321
1322 ```bash
1323 git add src/main.zig
1324 git commit -m "feat: headless mode — pty + vt proof of life"
1325 ```
1326
1327 ---
1328
1329 ## Phase 4: Font Rendering
1330
1331 ### Task 4.1: fontconfig lookup
1332
1333 **Files:**
1334 - Create: `src/font.zig`
1335 - Modify: `build.zig` (add font module, link fontconfig, freetype)
1336
1337 - [ ] **Step 1: Write failing test**
1338
1339 Create `src/font.zig`:
1340
1341 ```zig
1342 const std = @import("std");
1343 const c = @cImport({
1344 @cInclude("fontconfig/fontconfig.h");
1345 @cInclude("ft2build.h");
1346 @cInclude("freetype/freetype.h");
1347 });
1348
1349 pub const FontLookup = struct {
1350 path: [:0]u8,
1351 index: c_int,
1352
1353 pub fn deinit(self: *FontLookup, alloc: std.mem.Allocator) void {
1354 alloc.free(self.path);
1355 }
1356 };
1357
1358 pub fn lookupMonospace(alloc: std.mem.Allocator) !FontLookup {
1359 _ = alloc;
1360 return error.NotImplemented;
1361 }
1362
1363 test "lookupMonospace returns a valid font path" {
1364 var lookup = try lookupMonospace(std.testing.allocator);
1365 defer lookup.deinit(std.testing.allocator);
1366
1367 // Just check the file exists
1368 const file = try std.fs.openFileAbsolute(lookup.path, .{});
1369 file.close();
1370 }
1371 ```
1372
1373 - [ ] **Step 2: Register font module and tests in build.zig**
1374
1375 ```zig
1376 const font_module = b.addModule("font", .{
1377 .root_source_file = b.path("src/font.zig"),
1378 .target = target,
1379 .optimize = optimize,
1380 });
1381 font_module.link_libc = true;
1382 font_module.linkSystemLibrary("fontconfig", .{});
1383 font_module.linkSystemLibrary("freetype2", .{});
1384 exe.root_module.addImport("font", font_module);
1385
1386 const font_tests = b.addTest(.{
1387 .root_source_file = b.path("src/font.zig"),
1388 .target = target,
1389 .optimize = optimize,
1390 });
1391 font_tests.linkLibC();
1392 font_tests.linkSystemLibrary("fontconfig");
1393 font_tests.linkSystemLibrary("freetype2");
1394 test_step.dependOn(&b.addRunArtifact(font_tests).step);
1395 ```
1396
1397 - [ ] **Step 3: Run failing test**
1398
1399 ```bash
1400 zig build test
1401 ```
1402
1403 Expected: FAIL.
1404
1405 - [ ] **Step 4: Implement lookupMonospace**
1406
1407 ```zig
1408 pub fn lookupMonospace(alloc: std.mem.Allocator) !FontLookup {
1409 if (c.FcInit() == c.FcFalse) return error.FcInitFailed;
1410
1411 const pattern = c.FcPatternCreate() orelse return error.FcPatternCreate;
1412 defer c.FcPatternDestroy(pattern);
1413
1414 _ = c.FcPatternAddString(pattern, c.FC_FAMILY, "monospace");
1415 _ = c.FcPatternAddInteger(pattern, c.FC_WEIGHT, c.FC_WEIGHT_REGULAR);
1416 _ = c.FcPatternAddInteger(pattern, c.FC_SLANT, c.FC_SLANT_ROMAN);
1417
1418 _ = c.FcConfigSubstitute(null, pattern, c.FcMatchPattern);
1419 c.FcDefaultSubstitute(pattern);
1420
1421 var result: c.FcResult = undefined;
1422 const matched = c.FcFontMatch(null, pattern, &result) orelse return error.FcFontMatchFailed;
1423 defer c.FcPatternDestroy(matched);
1424
1425 var file_cstr: [*c]c.FcChar8 = null;
1426 if (c.FcPatternGetString(matched, c.FC_FILE, 0, &file_cstr) != c.FcResultMatch) {
1427 return error.FcGetFileFailed;
1428 }
1429
1430 var index: c_int = 0;
1431 _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index);
1432
1433 const slice = std.mem.span(@as([*:0]const u8, @ptrCast(file_cstr)));
1434 const dup = try alloc.dupeZ(u8, slice);
1435 return .{ .path = dup, .index = index };
1436 }
1437 ```
1438
1439 - [ ] **Step 5: Run test**
1440
1441 ```bash
1442 zig build test
1443 ```
1444
1445 Expected: PASS (requires fontconfig installed + a monospace font on the system).
1446
1447 - [ ] **Step 6: Commit**
1448
1449 ```bash
1450 git add src/font.zig build.zig
1451 git commit -m "feat(font): fontconfig monospace lookup"
1452 ```
1453
1454 ---
1455
1456 ### Task 4.2: Freetype face loading and glyph rasterization
1457
1458 **Files:**
1459 - Modify: `src/font.zig`
1460
1461 - [ ] **Step 1: Write failing test**
1462
1463 Add to `src/font.zig`:
1464
1465 ```zig
1466 test "Face rasterizes glyph 'M'" {
1467 var lookup = try lookupMonospace(std.testing.allocator);
1468 defer lookup.deinit(std.testing.allocator);
1469
1470 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
1471 defer face.deinit();
1472
1473 const glyph = try face.rasterize('M');
1474 try std.testing.expect(glyph.width > 0);
1475 try std.testing.expect(glyph.height > 0);
1476 try std.testing.expect(glyph.bitmap.len == @as(usize, glyph.width) * @as(usize, glyph.height));
1477 }
1478 ```
1479
1480 - [ ] **Step 2: Run failing test**
1481
1482 ```bash
1483 zig build test
1484 ```
1485
1486 Expected: FAIL.
1487
1488 - [ ] **Step 3: Implement Face**
1489
1490 Add to `src/font.zig`:
1491
1492 ```zig
1493 pub const Glyph = struct {
1494 codepoint: u21,
1495 width: u32,
1496 height: u32,
1497 bearing_x: i32,
1498 bearing_y: i32,
1499 advance_x: i32,
1500 bitmap: []u8, // R8, owned
1501 };
1502
1503 pub const Face = struct {
1504 alloc: std.mem.Allocator,
1505 library: c.FT_Library,
1506 face: c.FT_Face,
1507 px_size: u32,
1508
1509 pub fn init(alloc: std.mem.Allocator, path: [:0]const u8, index: c_int, px_size: u32) !Face {
1510 var library: c.FT_Library = null;
1511 if (c.FT_Init_FreeType(&library) != 0) return error.FtInitFailed;
1512 errdefer _ = c.FT_Done_FreeType(library);
1513
1514 var face: c.FT_Face = null;
1515 if (c.FT_New_Face(library, path.ptr, index, &face) != 0) return error.FtNewFaceFailed;
1516 errdefer _ = c.FT_Done_Face(face);
1517
1518 if (c.FT_Set_Pixel_Sizes(face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;
1519
1520 return .{
1521 .alloc = alloc,
1522 .library = library,
1523 .face = face,
1524 .px_size = px_size,
1525 };
1526 }
1527
1528 pub fn deinit(self: *Face) void {
1529 _ = c.FT_Done_Face(self.face);
1530 _ = c.FT_Done_FreeType(self.library);
1531 }
1532
1533 pub fn rasterize(self: *Face, codepoint: u21) !Glyph {
1534 const glyph_index = c.FT_Get_Char_Index(self.face, codepoint);
1535 if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) {
1536 return error.FtLoadGlyphFailed;
1537 }
1538
1539 const slot = self.face.*.glyph;
1540 const bitmap = slot.*.bitmap;
1541 const w: u32 = bitmap.width;
1542 const h: u32 = bitmap.rows;
1543
1544 const pixels = try self.alloc.alloc(u8, @as(usize, w) * @as(usize, h));
1545 // bitmap.buffer may be null if width/height are 0
1546 if (w > 0 and h > 0) {
1547 const pitch: i32 = bitmap.pitch;
1548 const abs_pitch: u32 = @intCast(@abs(pitch));
1549 var y: u32 = 0;
1550 while (y < h) : (y += 1) {
1551 const src_row = bitmap.buffer + @as(usize, y) * abs_pitch;
1552 const dst_row = pixels.ptr + @as(usize, y) * w;
1553 @memcpy(dst_row[0..w], src_row[0..w]);
1554 }
1555 }
1556
1557 return .{
1558 .codepoint = codepoint,
1559 .width = w,
1560 .height = h,
1561 .bearing_x = slot.*.bitmap_left,
1562 .bearing_y = slot.*.bitmap_top,
1563 .advance_x = @intCast(slot.*.advance.x >> 6),
1564 .bitmap = pixels,
1565 };
1566 }
1567
1568 pub fn freeGlyph(self: *Face, glyph: Glyph) void {
1569 self.alloc.free(glyph.bitmap);
1570 }
1571
1572 pub fn cellWidth(self: *Face) u32 {
1573 // Measure advance of 'M' for monospace cell width
1574 const m_index = c.FT_Get_Char_Index(self.face, 'M');
1575 _ = c.FT_Load_Glyph(self.face, m_index, c.FT_LOAD_DEFAULT);
1576 return @intCast(self.face.*.glyph.*.advance.x >> 6);
1577 }
1578
1579 pub fn cellHeight(self: *Face) u32 {
1580 const metrics = self.face.*.size.*.metrics;
1581 return @intCast((metrics.ascender - metrics.descender) >> 6);
1582 }
1583 };
1584 ```
1585
1586 - [ ] **Step 4: Run test**
1587
1588 ```bash
1589 zig build test
1590 ```
1591
1592 Expected: PASS.
1593
1594 - [ ] **Step 5: Commit**
1595
1596 ```bash
1597 git add src/font.zig
1598 git commit -m "feat(font): freetype face + rasterize"
1599 ```
1600
1601 ---
1602
1603 ### Task 4.3: Glyph atlas
1604
1605 **Files:**
1606 - Modify: `src/font.zig`
1607
1608 - [ ] **Step 1: Write failing test**
1609
1610 ```zig
1611 test "Atlas packs multiple glyphs and returns UVs" {
1612 var lookup = try lookupMonospace(std.testing.allocator);
1613 defer lookup.deinit(std.testing.allocator);
1614
1615 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
1616 defer face.deinit();
1617
1618 var atlas = try Atlas.init(std.testing.allocator, 512, 512);
1619 defer atlas.deinit();
1620
1621 const uv_m = try atlas.getOrInsert(&face, 'M');
1622 const uv_a = try atlas.getOrInsert(&face, 'a');
1623
1624 // Both should have valid UVs within [0, 1]
1625 try std.testing.expect(uv_m.u0 >= 0.0 and uv_m.u1 <= 1.0);
1626 try std.testing.expect(uv_a.u0 >= 0.0 and uv_a.u1 <= 1.0);
1627
1628 // Second call for 'M' should return cached UV (same values)
1629 const uv_m2 = try atlas.getOrInsert(&face, 'M');
1630 try std.testing.expectEqual(uv_m.u0, uv_m2.u0);
1631 try std.testing.expectEqual(uv_m.v0, uv_m2.v0);
1632 }
1633 ```
1634
1635 - [ ] **Step 2: Run failing test**
1636
1637 ```bash
1638 zig build test
1639 ```
1640
1641 Expected: FAIL.
1642
1643 - [ ] **Step 3: Implement Atlas**
1644
1645 Add to `src/font.zig`:
1646
1647 ```zig
1648 pub const GlyphUV = struct {
1649 u0: f32,
1650 v0: f32,
1651 u1: f32,
1652 v1: f32,
1653 width: u32,
1654 height: u32,
1655 bearing_x: i32,
1656 bearing_y: i32,
1657 advance_x: i32,
1658 };
1659
1660 pub const Atlas = struct {
1661 alloc: std.mem.Allocator,
1662 width: u32,
1663 height: u32,
1664 pixels: []u8, // R8
1665 // Row-based packer state
1666 cursor_x: u32,
1667 cursor_y: u32,
1668 row_height: u32,
1669 cache: std.AutoHashMap(u21, GlyphUV),
1670 dirty: bool,
1671
1672 pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas {
1673 const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height));
1674 @memset(pixels, 0);
1675 return .{
1676 .alloc = alloc,
1677 .width = width,
1678 .height = height,
1679 .pixels = pixels,
1680 .cursor_x = 0,
1681 .cursor_y = 0,
1682 .row_height = 0,
1683 .cache = std.AutoHashMap(u21, GlyphUV).init(alloc),
1684 .dirty = true,
1685 };
1686 }
1687
1688 pub fn deinit(self: *Atlas) void {
1689 self.alloc.free(self.pixels);
1690 self.cache.deinit();
1691 }
1692
1693 pub fn getOrInsert(self: *Atlas, face: *Face, codepoint: u21) !GlyphUV {
1694 if (self.cache.get(codepoint)) |uv| return uv;
1695
1696 const glyph = try face.rasterize(codepoint);
1697 defer face.freeGlyph(glyph);
1698
1699 // Advance to next row if this glyph doesn't fit
1700 if (self.cursor_x + glyph.width > self.width) {
1701 self.cursor_x = 0;
1702 self.cursor_y += self.row_height;
1703 self.row_height = 0;
1704 }
1705 if (self.cursor_y + glyph.height > self.height) {
1706 return error.AtlasFull;
1707 }
1708
1709 // Blit glyph into atlas
1710 var y: u32 = 0;
1711 while (y < glyph.height) : (y += 1) {
1712 const src = glyph.bitmap.ptr + @as(usize, y) * glyph.width;
1713 const dst = self.pixels.ptr + (@as(usize, self.cursor_y + y) * self.width) + self.cursor_x;
1714 @memcpy(dst[0..glyph.width], src[0..glyph.width]);
1715 }
1716
1717 const uv = GlyphUV{
1718 .u0 = @as(f32, @floatFromInt(self.cursor_x)) / @as(f32, @floatFromInt(self.width)),
1719 .v0 = @as(f32, @floatFromInt(self.cursor_y)) / @as(f32, @floatFromInt(self.height)),
1720 .u1 = @as(f32, @floatFromInt(self.cursor_x + glyph.width)) / @as(f32, @floatFromInt(self.width)),
1721 .v1 = @as(f32, @floatFromInt(self.cursor_y + glyph.height)) / @as(f32, @floatFromInt(self.height)),
1722 .width = glyph.width,
1723 .height = glyph.height,
1724 .bearing_x = glyph.bearing_x,
1725 .bearing_y = glyph.bearing_y,
1726 .advance_x = glyph.advance_x,
1727 };
1728
1729 try self.cache.put(codepoint, uv);
1730 self.cursor_x += glyph.width;
1731 if (glyph.height > self.row_height) self.row_height = glyph.height;
1732 self.dirty = true;
1733
1734 return uv;
1735 }
1736 };
1737 ```
1738
1739 - [ ] **Step 4: Run test**
1740
1741 ```bash
1742 zig build test
1743 ```
1744
1745 Expected: PASS.
1746
1747 - [ ] **Step 5: Commit**
1748
1749 ```bash
1750 git add src/font.zig
1751 git commit -m "feat(font): glyph atlas with row-based packing"
1752 ```
1753
1754 ---
1755
1756 ## Phase 5: Wayland Module
1757
1758 ### Task 5.1: Wire zig-wayland into build.zig
1759
1760 **Files:**
1761 - Create: `src/wayland.zig`
1762 - Modify: `build.zig`
1763
1764 - [ ] **Step 1: Create src/wayland.zig stub**
1765
1766 ```zig
1767 const std = @import("std");
1768 const wayland = @import("wayland");
1769 const wl = wayland.client.wl;
1770 const xdg = wayland.client.xdg;
1771
1772 test "wayland module imports" {
1773 _ = wl;
1774 _ = xdg;
1775 }
1776 ```
1777
1778 - [ ] **Step 2: Set up the zig-wayland scanner in build.zig**
1779
1780 Add near the top of `build` function:
1781
1782 ```zig
1783 const Scanner = @import("wayland").Scanner;
1784
1785 pub fn build(b: *std.Build) void {
1786 // ... existing code ...
1787
1788 const scanner = Scanner.create(b, .{});
1789 scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml");
1790 scanner.addSystemProtocol("staging/cursor-shape/cursor-shape-v1.xml");
1791 scanner.addSystemProtocol("staging/fractional-scale/fractional-scale-v1.xml");
1792 // Viewporter is required by fractional-scale
1793 scanner.addSystemProtocol("stable/viewporter/viewporter.xml");
1794
1795 scanner.generate("wl_compositor", 6);
1796 scanner.generate("wl_shm", 1);
1797 scanner.generate("wl_seat", 9);
1798 scanner.generate("wl_output", 4);
1799 scanner.generate("xdg_wm_base", 6);
1800 scanner.generate("wp_cursor_shape_manager_v1", 1);
1801 scanner.generate("wp_fractional_scale_manager_v1", 1);
1802 scanner.generate("wp_viewporter", 1);
1803
1804 // The wayland module from the dependency
1805 const wayland_dep = b.dependency("wayland", .{});
1806 const wayland_module = b.createModule(.{
1807 .root_source_file = scanner.result,
1808 });
1809 _ = wayland_dep;
1810
1811 // ... create exe ...
1812
1813 const wayland_src_module = b.addModule("wayland", .{
1814 .root_source_file = b.path("src/wayland.zig"),
1815 .target = target,
1816 .optimize = optimize,
1817 });
1818 wayland_src_module.addImport("wayland", wayland_module);
1819 wayland_src_module.link_libc = true;
1820 wayland_src_module.linkSystemLibrary("wayland-client", .{});
1821 exe.root_module.addImport("wayland-client", wayland_src_module);
1822 }
1823 ```
1824
1825 **Note:** this scanner block is approximate — consult the current `ifreund/zig-wayland` README, as the exact build.zig integration has changed over Zig releases. Adjust to match the version you pinned.
1826
1827 - [ ] **Step 3: Build**
1828
1829 ```bash
1830 zig build
1831 ```
1832
1833 Expected: builds successfully. Zig-wayland scans the protocol XMLs and generates bindings.
1834
1835 - [ ] **Step 4: Commit**
1836
1837 ```bash
1838 git add src/wayland.zig build.zig
1839 git commit -m "build(wayland): wire zig-wayland scanner"
1840 ```
1841
1842 ---
1843
1844 ### Task 5.2: Connect to wl_display and bind globals
1845
1846 **Files:**
1847 - Modify: `src/wayland.zig`
1848
1849 - [ ] **Step 1: Write a minimal Wayland client that binds compositor, xdg_wm_base, seat**
1850
1851 ```zig
1852 const std = @import("std");
1853 const wayland = @import("wayland");
1854 const wl = wayland.client.wl;
1855 const xdg = wayland.client.xdg;
1856
1857 pub const Globals = struct {
1858 compositor: ?*wl.Compositor = null,
1859 wm_base: ?*xdg.WmBase = null,
1860 seat: ?*wl.Seat = null,
1861 };
1862
1863 pub const Connection = struct {
1864 display: *wl.Display,
1865 registry: *wl.Registry,
1866 globals: Globals,
1867
1868 pub fn init() !Connection {
1869 const display = try wl.Display.connect(null);
1870 const registry = try display.getRegistry();
1871
1872 var globals = Globals{};
1873 registry.setListener(*Globals, registryListener, &globals);
1874
1875 _ = display.roundtrip();
1876
1877 if (globals.compositor == null) return error.NoCompositor;
1878 if (globals.wm_base == null) return error.NoXdgWmBase;
1879 if (globals.seat == null) return error.NoSeat;
1880
1881 return .{
1882 .display = display,
1883 .registry = registry,
1884 .globals = globals,
1885 };
1886 }
1887
1888 pub fn deinit(self: *Connection) void {
1889 self.display.disconnect();
1890 }
1891 };
1892
1893 fn registryListener(
1894 registry: *wl.Registry,
1895 event: wl.Registry.Event,
1896 globals: *Globals,
1897 ) void {
1898 switch (event) {
1899 .global => |g| {
1900 const iface = std.mem.span(g.interface);
1901 if (std.mem.eql(u8, iface, wl.Compositor.interface.name)) {
1902 globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
1903 } else if (std.mem.eql(u8, iface, xdg.WmBase.interface.name)) {
1904 globals.wm_base = registry.bind(g.name, xdg.WmBase, 6) catch return;
1905 } else if (std.mem.eql(u8, iface, wl.Seat.interface.name)) {
1906 globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
1907 }
1908 },
1909 .global_remove => {},
1910 }
1911 }
1912 ```
1913
1914 **Adjust the exact interface names, versions, and event field names to match the generated `zig-wayland` bindings.** If `g.interface` is `[*:0]const u8`, `std.mem.span` works; if it's a different type, use the matching accessor.
1915
1916 - [ ] **Step 2: Build**
1917
1918 ```bash
1919 zig build
1920 ```
1921
1922 Expected: builds. Won't crash without a live Wayland session because we aren't running it yet.
1923
1924 - [ ] **Step 3: Smoke-test under a live Wayland session**
1925
1926 In your Wayland session, run:
1927
1928 ```bash
1929 zig build
1930 ./zig-out/bin/waystty --wayland-smoke-test
1931 ```
1932
1933 For now, add a `--wayland-smoke-test` branch to `main.zig` that calls `wayland.Connection.init()` and prints "connected" on success. Commit both the branch and the connection wiring together.
1934
1935 Expected: prints `connected`.
1936
1937 - [ ] **Step 4: Commit**
1938
1939 ```bash
1940 git add src/wayland.zig src/main.zig
1941 git commit -m "feat(wayland): connect to display and bind globals"
1942 ```
1943
1944 ---
1945
1946 ### Task 5.3: Create surface and xdg_toplevel
1947
1948 **Files:**
1949 - Modify: `src/wayland.zig`
1950
1951 - [ ] **Step 1: Extend Connection with createWindow**
1952
1953 ```zig
1954 pub const Window = struct {
1955 surface: *wl.Surface,
1956 xdg_surface: *xdg.Surface,
1957 xdg_toplevel: *xdg.Toplevel,
1958 configured: bool = false,
1959 should_close: bool = false,
1960 width: u32 = 800,
1961 height: u32 = 600,
1962
1963 pub fn deinit(self: *Window) void {
1964 self.xdg_toplevel.destroy();
1965 self.xdg_surface.destroy();
1966 self.surface.destroy();
1967 }
1968 };
1969
1970 pub fn createWindow(self: *Connection, title: [*:0]const u8) !*Window {
1971 const compositor = self.globals.compositor orelse return error.NoCompositor;
1972 const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;
1973
1974 const window = try std.heap.c_allocator.create(Window);
1975 window.* = .{
1976 .surface = try compositor.createSurface(),
1977 .xdg_surface = undefined,
1978 .xdg_toplevel = undefined,
1979 };
1980
1981 window.xdg_surface = try wm_base.getXdgSurface(window.surface);
1982 window.xdg_toplevel = try window.xdg_surface.getToplevel();
1983
1984 window.xdg_toplevel.setTitle(title);
1985 window.xdg_toplevel.setAppId("waystty");
1986
1987 window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
1988 window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
1989 wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);
1990
1991 window.surface.commit();
1992 _ = self.display.roundtrip();
1993
1994 return window;
1995 }
1996
1997 fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
1998 switch (event) {
1999 .ping => |p| wm_base.pong(p.serial),
2000 }
2001 }
2002
2003 fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
2004 switch (event) {
2005 .configure => |c| {
2006 surface.ackConfigure(c.serial);
2007 window.configured = true;
2008 },
2009 }
2010 }
2011
2012 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
2013 switch (event) {
2014 .configure => |c| {
2015 if (c.width > 0) window.width = @intCast(c.width);
2016 if (c.height > 0) window.height = @intCast(c.height);
2017 },
2018 .close => window.should_close = true,
2019 else => {},
2020 }
2021 }
2022 ```
2023
2024 - [ ] **Step 2: Extend smoke test to create window**
2025
2026 In `main.zig --wayland-smoke-test`, after connecting, create a window and call `display.roundtrip()` twice, then print `window created`.
2027
2028 - [ ] **Step 3: Run smoke test**
2029
2030 ```bash
2031 zig build run -- --wayland-smoke-test
2032 ```
2033
2034 Expected: prints `connected`, `window created`, exits cleanly.
2035
2036 - [ ] **Step 4: Commit**
2037
2038 ```bash
2039 git add src/wayland.zig src/main.zig
2040 git commit -m "feat(wayland): create surface + xdg_toplevel"
2041 ```
2042
2043 ---
2044
2045 ### Task 5.4: Keyboard input with xkbcommon
2046
2047 **Files:**
2048 - Modify: `src/wayland.zig`
2049 - Modify: `build.zig` (link xkbcommon)
2050
2051 - [ ] **Step 1: Link xkbcommon in build.zig**
2052
2053 In the `wayland_src_module` block:
2054
2055 ```zig
2056 wayland_src_module.linkSystemLibrary("xkbcommon", .{});
2057 ```
2058
2059 - [ ] **Step 2: Add xkbcommon cImport and keyboard struct**
2060
2061 At top of `src/wayland.zig`:
2062
2063 ```zig
2064 const c = @cImport({
2065 @cInclude("xkbcommon/xkbcommon.h");
2066 @cInclude("sys/mman.h");
2067 @cInclude("unistd.h");
2068 });
2069
2070 pub const KeyboardEvent = struct {
2071 keysym: u32,
2072 modifiers: Modifiers,
2073 action: Action,
2074 utf8: [8]u8,
2075 utf8_len: u8,
2076
2077 pub const Modifiers = struct {
2078 ctrl: bool = false,
2079 shift: bool = false,
2080 alt: bool = false,
2081 super: bool = false,
2082 };
2083
2084 pub const Action = enum { press, release, repeat };
2085 };
2086
2087 pub const Keyboard = struct {
2088 wl_keyboard: *wl.Keyboard,
2089 xkb_ctx: ?*c.xkb_context,
2090 xkb_keymap: ?*c.xkb_keymap = null,
2091 xkb_state: ?*c.xkb_state = null,
2092 event_queue: std.ArrayList(KeyboardEvent),
2093 // repeat info
2094 repeat_rate: u32 = 25, // keys per second
2095 repeat_delay: u32 = 500, // ms
2096 last_key: ?u32 = null,
2097 last_key_time_ns: i128 = 0,
2098 has_focus: bool = false,
2099
2100 pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Keyboard {
2101 const kb = try alloc.create(Keyboard);
2102 kb.* = .{
2103 .wl_keyboard = try seat.getKeyboard(),
2104 .xkb_ctx = c.xkb_context_new(c.XKB_CONTEXT_NO_FLAGS),
2105 .event_queue = std.ArrayList(KeyboardEvent).init(alloc),
2106 };
2107 kb.wl_keyboard.setListener(*Keyboard, keyboardListener, kb);
2108 return kb;
2109 }
2110
2111 pub fn deinit(self: *Keyboard, alloc: std.mem.Allocator) void {
2112 if (self.xkb_state) |s| c.xkb_state_unref(s);
2113 if (self.xkb_keymap) |m| c.xkb_keymap_unref(m);
2114 if (self.xkb_ctx) |ctx| c.xkb_context_unref(ctx);
2115 self.wl_keyboard.release();
2116 self.event_queue.deinit();
2117 alloc.destroy(self);
2118 }
2119 };
2120
2121 fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) void {
2122 switch (event) {
2123 .keymap => |k| {
2124 if (k.format != .xkb_v1) return;
2125 const map_mem = c.mmap(
2126 null,
2127 k.size,
2128 c.PROT_READ,
2129 c.MAP_PRIVATE,
2130 k.fd,
2131 0,
2132 );
2133 defer _ = c.munmap(map_mem, k.size);
2134 defer _ = c.close(k.fd);
2135
2136 if (map_mem == c.MAP_FAILED) return;
2137
2138 const new_keymap = c.xkb_keymap_new_from_string(
2139 kb.xkb_ctx,
2140 @ptrCast(map_mem),
2141 c.XKB_KEYMAP_FORMAT_TEXT_V1,
2142 c.XKB_KEYMAP_COMPILE_NO_FLAGS,
2143 ) orelse return;
2144
2145 const new_state = c.xkb_state_new(new_keymap) orelse {
2146 c.xkb_keymap_unref(new_keymap);
2147 return;
2148 };
2149
2150 if (kb.xkb_state) |s| c.xkb_state_unref(s);
2151 if (kb.xkb_keymap) |m| c.xkb_keymap_unref(m);
2152 kb.xkb_keymap = new_keymap;
2153 kb.xkb_state = new_state;
2154 },
2155 .enter => {
2156 kb.has_focus = true;
2157 },
2158 .leave => {
2159 kb.has_focus = false;
2160 kb.last_key = null;
2161 },
2162 .key => |k| {
2163 const state = kb.xkb_state orelse return;
2164 const keycode: u32 = k.key + 8; // evdev -> xkb offset
2165 const keysym = c.xkb_state_key_get_one_sym(state, keycode);
2166
2167 var utf8: [8]u8 = undefined;
2168 const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);
2169
2170 const action: KeyboardEvent.Action = if (k.state == .pressed) .press else .release;
2171
2172 const mods = KeyboardEvent.Modifiers{
2173 .ctrl = c.xkb_state_mod_name_is_active(state, "Control", c.XKB_STATE_MODS_EFFECTIVE) > 0,
2174 .shift = c.xkb_state_mod_name_is_active(state, "Shift", c.XKB_STATE_MODS_EFFECTIVE) > 0,
2175 .alt = c.xkb_state_mod_name_is_active(state, "Mod1", c.XKB_STATE_MODS_EFFECTIVE) > 0,
2176 .super = c.xkb_state_mod_name_is_active(state, "Mod4", c.XKB_STATE_MODS_EFFECTIVE) > 0,
2177 };
2178
2179 var ev = KeyboardEvent{
2180 .keysym = keysym,
2181 .modifiers = mods,
2182 .action = action,
2183 .utf8 = utf8,
2184 .utf8_len = @intCast(@min(len, 8)),
2185 };
2186
2187 kb.event_queue.append(ev) catch return;
2188
2189 if (action == .press) {
2190 kb.last_key = keycode;
2191 kb.last_key_time_ns = std.time.nanoTimestamp();
2192 } else if (kb.last_key == keycode) {
2193 kb.last_key = null;
2194 }
2195 },
2196 .modifiers => |m| {
2197 const state = kb.xkb_state orelse return;
2198 _ = c.xkb_state_update_mask(
2199 state,
2200 m.mods_depressed,
2201 m.mods_latched,
2202 m.mods_locked,
2203 0,
2204 0,
2205 m.group,
2206 );
2207 },
2208 .repeat_info => |r| {
2209 kb.repeat_rate = @intCast(r.rate);
2210 kb.repeat_delay = @intCast(r.delay);
2211 },
2212 }
2213 }
2214 ```
2215
2216 - [ ] **Step 3: Build**
2217
2218 ```bash
2219 zig build
2220 ```
2221
2222 Expected: builds.
2223
2224 - [ ] **Step 4: Commit**
2225
2226 ```bash
2227 git add src/wayland.zig build.zig
2228 git commit -m "feat(wayland): keyboard input with xkbcommon"
2229 ```
2230
2231 ---
2232
2233 ### Task 5.5: Key repeat tick
2234
2235 **Files:**
2236 - Modify: `src/wayland.zig`
2237
2238 - [ ] **Step 1: Add tickRepeat method**
2239
2240 Add to `Keyboard`:
2241
2242 ```zig
2243 /// Called from the main loop. If a key is currently held and enough time has
2244 /// passed, push a synthetic repeat event to the queue.
2245 pub fn tickRepeat(self: *Keyboard) void {
2246 const last_key = self.last_key orelse return;
2247 const state = self.xkb_state orelse return;
2248
2249 const now = std.time.nanoTimestamp();
2250 const elapsed_ms = @divTrunc(now - self.last_key_time_ns, std.time.ns_per_ms);
2251 if (elapsed_ms < @as(i128, self.repeat_delay)) return;
2252
2253 const interval_ms: i128 = @divTrunc(1000, @as(i128, self.repeat_rate));
2254 const repeats_due = @divTrunc(elapsed_ms - self.repeat_delay, interval_ms) + 1;
2255 _ = repeats_due;
2256
2257 // Simple approach: fire one repeat per tick, advance last_key_time_ns
2258 const keysym = c.xkb_state_key_get_one_sym(state, last_key);
2259 var utf8: [8]u8 = undefined;
2260 const len = c.xkb_state_key_get_utf8(state, last_key, &utf8, utf8.len);
2261
2262 const ev = KeyboardEvent{
2263 .keysym = keysym,
2264 .modifiers = .{},
2265 .action = .repeat,
2266 .utf8 = utf8,
2267 .utf8_len = @intCast(@min(len, 8)),
2268 };
2269 self.event_queue.append(ev) catch return;
2270 self.last_key_time_ns = now;
2271 }
2272 ```
2273
2274 - [ ] **Step 2: Build**
2275
2276 ```bash
2277 zig build
2278 ```
2279
2280 Expected: builds.
2281
2282 - [ ] **Step 3: Commit**
2283
2284 ```bash
2285 git add src/wayland.zig
2286 git commit -m "feat(wayland): client-side key repeat"
2287 ```
2288
2289 ---
2290
2291 ## Phase 6: Vulkan Renderer
2292
2293 ### Task 6.1: Wire vulkan-zig into build.zig
2294
2295 **Files:**
2296 - Create: `src/renderer.zig`
2297 - Modify: `build.zig`
2298
2299 - [ ] **Step 1: Create renderer.zig stub**
2300
2301 ```zig
2302 const std = @import("std");
2303 const vk = @import("vulkan");
2304
2305 test "vulkan module imports" {
2306 _ = vk;
2307 }
2308 ```
2309
2310 - [ ] **Step 2: Wire vulkan-zig in build.zig**
2311
2312 ```zig
2313 const vulkan_headers_dep = b.dependency("vulkan_headers", .{});
2314 const vulkan_zig_dep = b.dependency("vulkan", .{
2315 .registry = vulkan_headers_dep.path("registry/vk.xml"),
2316 });
2317 const vulkan_module = vulkan_zig_dep.module("vulkan-zig");
2318
2319 const renderer_module = b.addModule("renderer", .{
2320 .root_source_file = b.path("src/renderer.zig"),
2321 .target = target,
2322 .optimize = optimize,
2323 });
2324 renderer_module.addImport("vulkan", vulkan_module);
2325 exe.root_module.addImport("renderer", renderer_module);
2326 ```
2327
2328 - [ ] **Step 3: Build**
2329
2330 ```bash
2331 zig build
2332 ```
2333
2334 Expected: builds. vulkan-zig generates bindings from vk.xml.
2335
2336 - [ ] **Step 4: Commit**
2337
2338 ```bash
2339 git add src/renderer.zig build.zig
2340 git commit -m "build(renderer): wire vulkan-zig"
2341 ```
2342
2343 ---
2344
2345 ### Task 6.2: Shaders — cell.vert and cell.frag
2346
2347 **Files:**
2348 - Create: `shaders/cell.vert`
2349 - Create: `shaders/cell.frag`
2350 - Modify: `build.zig` (add glslc build step)
2351
2352 - [ ] **Step 1: Write cell.vert (GLSL)**
2353
2354 ```glsl
2355 #version 450
2356
2357 layout(push_constant) uniform PushConstants {
2358 vec2 viewport_size;
2359 vec2 cell_size;
2360 } pc;
2361
2362 layout(location = 0) in vec2 in_unit_pos; // [0,1] quad corner
2363
2364 layout(location = 1) in vec2 in_cell_pos; // cell grid coords
2365 layout(location = 2) in vec4 in_uv_rect; // u0,v0,u1,v1
2366 layout(location = 3) in vec4 in_fg_color;
2367 layout(location = 4) in vec4 in_bg_color;
2368
2369 layout(location = 0) out vec2 out_uv;
2370 layout(location = 1) out vec4 out_fg;
2371 layout(location = 2) out vec4 out_bg;
2372
2373 void main() {
2374 vec2 pixel_pos = (in_cell_pos + in_unit_pos) * pc.cell_size;
2375 vec2 ndc = (pixel_pos / pc.viewport_size) * 2.0 - 1.0;
2376 gl_Position = vec4(ndc, 0.0, 1.0);
2377
2378 out_uv = mix(in_uv_rect.xy, in_uv_rect.zw, in_unit_pos);
2379 out_fg = in_fg_color;
2380 out_bg = in_bg_color;
2381 }
2382 ```
2383
2384 - [ ] **Step 2: Write cell.frag (GLSL)**
2385
2386 ```glsl
2387 #version 450
2388
2389 layout(binding = 0) uniform sampler2D glyph_atlas;
2390
2391 layout(location = 0) in vec2 in_uv;
2392 layout(location = 1) in vec4 in_fg;
2393 layout(location = 2) in vec4 in_bg;
2394
2395 layout(location = 0) out vec4 out_color;
2396
2397 void main() {
2398 float alpha = texture(glyph_atlas, in_uv).r;
2399 out_color = mix(in_bg, in_fg, alpha);
2400 }
2401 ```
2402
2403 - [ ] **Step 3: Add glslc build step in build.zig**
2404
2405 ```zig
2406 fn compileShader(b: *std.Build, comptime name: []const u8) std.Build.LazyPath {
2407 const glslc = b.addSystemCommand(&.{ "glslc", "--target-env=vulkan1.2" });
2408 glslc.addFileArg(b.path("shaders/" ++ name));
2409 glslc.addArg("-o");
2410 return glslc.addOutputFileArg(name ++ ".spv");
2411 }
2412
2413 // In build():
2414 const vert_spv = compileShader(b, "cell.vert");
2415 const frag_spv = compileShader(b, "cell.frag");
2416
2417 renderer_module.addAnonymousImport("cell_vert_spv", .{
2418 .root_source_file = vert_spv,
2419 });
2420 renderer_module.addAnonymousImport("cell_frag_spv", .{
2421 .root_source_file = frag_spv,
2422 });
2423 ```
2424
2425 Then in `renderer.zig` access via `@embedFile` equivalent — vulkan-zig expects a byte slice. The above uses `addAnonymousImport` which isn't right for raw bytes. Actually use this simpler approach:
2426
2427 ```zig
2428 const vert_install = b.addInstallFile(vert_spv, "shaders/cell.vert.spv");
2429 const frag_install = b.addInstallFile(frag_spv, "shaders/cell.frag.spv");
2430 exe.step.dependOn(&vert_install.step);
2431 exe.step.dependOn(&frag_install.step);
2432 ```
2433
2434 And in `renderer.zig` load at runtime, OR use Zig's `@embedFile` by copying SPV into `src/` via the build step. The cleanest approach for embed-at-compile-time:
2435
2436 ```zig
2437 const install_shaders = b.addWriteFiles();
2438 _ = install_shaders.addCopyFile(vert_spv, "cell.vert.spv");
2439 _ = install_shaders.addCopyFile(frag_spv, "cell.frag.spv");
2440 renderer_module.addIncludePath(install_shaders.getDirectory());
2441 ```
2442
2443 Then in Zig use `@embedFile` with a path resolved via the include path. If this turns out not to work cleanly, fall back to `@import` of a generated `.zig` file that contains `pub const cell_vert_spv = @embedFile("cell.vert.spv");`.
2444
2445 **This task is intentionally flexible** — the exact mechanism depends on Zig version. The key deliverable: at the end of this task, renderer.zig can reference `const vert_spv = @embedFile("cell.vert.spv");` and it works.
2446
2447 - [ ] **Step 4: Verify with a test**
2448
2449 In `renderer.zig`:
2450
2451 ```zig
2452 test "shaders are embedded" {
2453 const vert = @embedFile("cell.vert.spv");
2454 const frag = @embedFile("cell.frag.spv");
2455 try std.testing.expect(vert.len > 0);
2456 try std.testing.expect(frag.len > 0);
2457 // SPIR-V magic number
2458 try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.bytesAsSlice(u32, vert[0..4])[0]);
2459 }
2460 ```
2461
2462 - [ ] **Step 5: Build and test**
2463
2464 ```bash
2465 zig build test
2466 ```
2467
2468 Expected: PASS.
2469
2470 - [ ] **Step 6: Commit**
2471
2472 ```bash
2473 git add shaders/ src/renderer.zig build.zig
2474 git commit -m "build(shaders): glslc compile + embed"
2475 ```
2476
2477 ---
2478
2479 ### Task 6.3: Vulkan instance + wayland surface
2480
2481 **Files:**
2482 - Modify: `src/renderer.zig`
2483
2484 - [ ] **Step 1: Create Vulkan instance with wayland extension**
2485
2486 ```zig
2487 const std = @import("std");
2488 const vk = @import("vulkan");
2489
2490 const apis: []const vk.ApiInfo = &.{
2491 vk.features.version_1_0,
2492 vk.features.version_1_1,
2493 vk.features.version_1_2,
2494 vk.extensions.khr_surface,
2495 vk.extensions.khr_wayland_surface,
2496 vk.extensions.khr_swapchain,
2497 };
2498
2499 const BaseDispatch = vk.BaseWrapper(apis);
2500 const InstanceDispatch = vk.InstanceWrapper(apis);
2501 const DeviceDispatch = vk.DeviceWrapper(apis);
2502
2503 pub const Renderer = struct {
2504 alloc: std.mem.Allocator,
2505 vkb: BaseDispatch,
2506 vki: InstanceDispatch,
2507 instance: vk.Instance,
2508
2509 pub fn init(alloc: std.mem.Allocator, loader: anytype) !Renderer {
2510 const vkb = try BaseDispatch.load(loader);
2511
2512 const app_info = vk.ApplicationInfo{
2513 .p_application_name = "waystty",
2514 .application_version = vk.makeApiVersion(0, 0, 0, 1),
2515 .p_engine_name = "waystty",
2516 .engine_version = vk.makeApiVersion(0, 0, 0, 1),
2517 .api_version = vk.API_VERSION_1_2,
2518 };
2519
2520 const extensions = [_][*:0]const u8{
2521 vk.extensions.khr_surface.name,
2522 vk.extensions.khr_wayland_surface.name,
2523 };
2524
2525 const instance = try vkb.createInstance(&.{
2526 .p_application_info = &app_info,
2527 .enabled_extension_count = extensions.len,
2528 .pp_enabled_extension_names = &extensions,
2529 }, null);
2530
2531 const vki = try InstanceDispatch.load(instance, vkb.dispatch.vkGetInstanceProcAddr);
2532
2533 return .{
2534 .alloc = alloc,
2535 .vkb = vkb,
2536 .vki = vki,
2537 .instance = instance,
2538 };
2539 }
2540
2541 pub fn deinit(self: *Renderer) void {
2542 self.vki.destroyInstance(self.instance, null);
2543 }
2544
2545 pub fn createWaylandSurface(
2546 self: *Renderer,
2547 display: *anyopaque,
2548 surface: *anyopaque,
2549 ) !vk.SurfaceKHR {
2550 return try self.vki.createWaylandSurfaceKHR(self.instance, &.{
2551 .display = @ptrCast(display),
2552 .surface = @ptrCast(surface),
2553 }, null);
2554 }
2555 };
2556 ```
2557
2558 **The exact vulkan-zig API (apis tuple, function names, field names) must be verified against the current version of vulkan-zig. Adjust as needed.**
2559
2560 - [ ] **Step 2: Build**
2561
2562 ```bash
2563 zig build
2564 ```
2565
2566 Expected: builds. Will fail on many small API mismatches that need to be fixed.
2567
2568 - [ ] **Step 3: Commit**
2569
2570 ```bash
2571 git add src/renderer.zig
2572 git commit -m "feat(renderer): Vulkan instance + wayland surface creation"
2573 ```
2574
2575 ---
2576
2577 ### Task 6.4: Physical device + logical device + queues
2578
2579 **Files:**
2580 - Modify: `src/renderer.zig`
2581
2582 - [ ] **Step 1: Add device selection**
2583
2584 Extend `Renderer`:
2585
2586 ```zig
2587 pub const DeviceInfo = struct {
2588 physical: vk.PhysicalDevice,
2589 graphics_queue_family: u32,
2590 present_queue_family: u32,
2591 };
2592
2593 pub fn pickPhysicalDevice(self: *Renderer, surface: vk.SurfaceKHR) !DeviceInfo {
2594 var count: u32 = 0;
2595 _ = try self.vki.enumeratePhysicalDevices(self.instance, &count, null);
2596
2597 const devices = try self.alloc.alloc(vk.PhysicalDevice, count);
2598 defer self.alloc.free(devices);
2599 _ = try self.vki.enumeratePhysicalDevices(self.instance, &count, devices.ptr);
2600
2601 for (devices[0..count]) |pd| {
2602 var qf_count: u32 = 0;
2603 self.vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, null);
2604 const qfs = try self.alloc.alloc(vk.QueueFamilyProperties, qf_count);
2605 defer self.alloc.free(qfs);
2606 self.vki.getPhysicalDeviceQueueFamilyProperties(pd, &qf_count, qfs.ptr);
2607
2608 var graphics_idx: ?u32 = null;
2609 var present_idx: ?u32 = null;
2610 for (qfs[0..qf_count], 0..) |qf, i| {
2611 if (qf.queue_flags.graphics_bit) graphics_idx = @intCast(i);
2612 var supported: vk.Bool32 = vk.FALSE;
2613 _ = try self.vki.getPhysicalDeviceSurfaceSupportKHR(pd, @intCast(i), surface, &supported);
2614 if (supported == vk.TRUE) present_idx = @intCast(i);
2615 if (graphics_idx != null and present_idx != null) break;
2616 }
2617
2618 if (graphics_idx != null and present_idx != null) {
2619 return .{
2620 .physical = pd,
2621 .graphics_queue_family = graphics_idx.?,
2622 .present_queue_family = present_idx.?,
2623 };
2624 }
2625 }
2626 return error.NoSuitableDevice;
2627 }
2628 ```
2629
2630 - [ ] **Step 2: Add logical device creation**
2631
2632 ```zig
2633 pub const Device = struct {
2634 vkd: DeviceDispatch,
2635 handle: vk.Device,
2636 graphics_queue: vk.Queue,
2637 present_queue: vk.Queue,
2638 };
2639
2640 pub fn createDevice(self: *Renderer, info: DeviceInfo) !Device {
2641 const priority: f32 = 1.0;
2642 var unique_families = [_]u32{ info.graphics_queue_family, info.present_queue_family };
2643 // deduplicate
2644 var unique_count: u32 = 1;
2645 if (info.graphics_queue_family != info.present_queue_family) unique_count = 2;
2646
2647 var queue_infos: [2]vk.DeviceQueueCreateInfo = undefined;
2648 for (0..unique_count) |i| {
2649 queue_infos[i] = .{
2650 .queue_family_index = unique_families[i],
2651 .queue_count = 1,
2652 .p_queue_priorities = @ptrCast(&priority),
2653 };
2654 }
2655
2656 const exts = [_][*:0]const u8{vk.extensions.khr_swapchain.name};
2657
2658 const device = try self.vki.createDevice(info.physical, &.{
2659 .queue_create_info_count = unique_count,
2660 .p_queue_create_infos = &queue_infos,
2661 .enabled_extension_count = exts.len,
2662 .pp_enabled_extension_names = &exts,
2663 }, null);
2664
2665 const vkd = try DeviceDispatch.load(device, self.vki.dispatch.vkGetDeviceProcAddr);
2666
2667 return .{
2668 .vkd = vkd,
2669 .handle = device,
2670 .graphics_queue = vkd.getDeviceQueue(device, info.graphics_queue_family, 0),
2671 .present_queue = vkd.getDeviceQueue(device, info.present_queue_family, 0),
2672 };
2673 }
2674 ```
2675
2676 - [ ] **Step 3: Build**
2677
2678 ```bash
2679 zig build
2680 ```
2681
2682 Expected: builds.
2683
2684 - [ ] **Step 4: Commit**
2685
2686 ```bash
2687 git add src/renderer.zig
2688 git commit -m "feat(renderer): physical + logical device selection"
2689 ```
2690
2691 ---
2692
2693 ### Task 6.5: Swapchain
2694
2695 **Files:**
2696 - Modify: `src/renderer.zig`
2697
2698 - [ ] **Step 1: Implement swapchain creation**
2699
2700 ```zig
2701 pub const Swapchain = struct {
2702 handle: vk.SwapchainKHR,
2703 format: vk.Format,
2704 extent: vk.Extent2D,
2705 images: []vk.Image,
2706 image_views: []vk.ImageView,
2707 };
2708
2709 pub fn createSwapchain(
2710 self: *Renderer,
2711 device: *Device,
2712 info: DeviceInfo,
2713 surface: vk.SurfaceKHR,
2714 width: u32,
2715 height: u32,
2716 ) !Swapchain {
2717 const caps = try self.vki.getPhysicalDeviceSurfaceCapabilitiesKHR(info.physical, surface);
2718
2719 var fmt_count: u32 = 0;
2720 _ = try self.vki.getPhysicalDeviceSurfaceFormatsKHR(info.physical, surface, &fmt_count, null);
2721 const formats = try self.alloc.alloc(vk.SurfaceFormatKHR, fmt_count);
2722 defer self.alloc.free(formats);
2723 _ = try self.vki.getPhysicalDeviceSurfaceFormatsKHR(info.physical, surface, &fmt_count, formats.ptr);
2724
2725 var chosen_format = formats[0];
2726 for (formats[0..fmt_count]) |f| {
2727 if (f.format == .b8g8r8a8_srgb and f.color_space == .srgb_nonlinear_khr) {
2728 chosen_format = f;
2729 break;
2730 }
2731 }
2732
2733 var extent = caps.current_extent;
2734 if (extent.width == 0xFFFFFFFF) {
2735 extent = .{ .width = width, .height = height };
2736 }
2737
2738 var image_count: u32 = caps.min_image_count + 1;
2739 if (caps.max_image_count > 0 and image_count > caps.max_image_count) {
2740 image_count = caps.max_image_count;
2741 }
2742
2743 const same_family = info.graphics_queue_family == info.present_queue_family;
2744 const families = [_]u32{ info.graphics_queue_family, info.present_queue_family };
2745
2746 const handle = try device.vkd.createSwapchainKHR(device.handle, &.{
2747 .surface = surface,
2748 .min_image_count = image_count,
2749 .image_format = chosen_format.format,
2750 .image_color_space = chosen_format.color_space,
2751 .image_extent = extent,
2752 .image_array_layers = 1,
2753 .image_usage = .{ .color_attachment_bit = true },
2754 .image_sharing_mode = if (same_family) .exclusive else .concurrent,
2755 .queue_family_index_count = if (same_family) 0 else 2,
2756 .p_queue_family_indices = if (same_family) null else &families,
2757 .pre_transform = caps.current_transform,
2758 .composite_alpha = .{ .opaque_bit_khr = true },
2759 .present_mode = .fifo_khr,
2760 .clipped = vk.TRUE,
2761 }, null);
2762
2763 var sc_image_count: u32 = 0;
2764 _ = try device.vkd.getSwapchainImagesKHR(device.handle, handle, &sc_image_count, null);
2765 const images = try self.alloc.alloc(vk.Image, sc_image_count);
2766 _ = try device.vkd.getSwapchainImagesKHR(device.handle, handle, &sc_image_count, images.ptr);
2767
2768 const image_views = try self.alloc.alloc(vk.ImageView, sc_image_count);
2769 for (images, image_views) |img, *view| {
2770 view.* = try device.vkd.createImageView(device.handle, &.{
2771 .image = img,
2772 .view_type = .@"2d",
2773 .format = chosen_format.format,
2774 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
2775 .subresource_range = .{
2776 .aspect_mask = .{ .color_bit = true },
2777 .base_mip_level = 0,
2778 .level_count = 1,
2779 .base_array_layer = 0,
2780 .layer_count = 1,
2781 },
2782 }, null);
2783 }
2784
2785 return .{
2786 .handle = handle,
2787 .format = chosen_format.format,
2788 .extent = extent,
2789 .images = images,
2790 .image_views = image_views,
2791 };
2792 }
2793 ```
2794
2795 - [ ] **Step 2: Build**
2796
2797 ```bash
2798 zig build
2799 ```
2800
2801 Expected: builds.
2802
2803 - [ ] **Step 3: Commit**
2804
2805 ```bash
2806 git add src/renderer.zig
2807 git commit -m "feat(renderer): swapchain with FIFO present mode"
2808 ```
2809
2810 ---
2811
2812 ### Task 6.6: Render pass + framebuffers
2813
2814 **Files:**
2815 - Modify: `src/renderer.zig`
2816
2817 - [ ] **Step 1: Implement render pass**
2818
2819 ```zig
2820 pub fn createRenderPass(device: *Device, format: vk.Format) !vk.RenderPass {
2821 const color_attachment = vk.AttachmentDescription{
2822 .format = format,
2823 .samples = .{ .@"1_bit" = true },
2824 .load_op = .clear,
2825 .store_op = .store,
2826 .stencil_load_op = .dont_care,
2827 .stencil_store_op = .dont_care,
2828 .initial_layout = .undefined,
2829 .final_layout = .present_src_khr,
2830 };
2831
2832 const color_ref = vk.AttachmentReference{
2833 .attachment = 0,
2834 .layout = .color_attachment_optimal,
2835 };
2836
2837 const subpass = vk.SubpassDescription{
2838 .pipeline_bind_point = .graphics,
2839 .color_attachment_count = 1,
2840 .p_color_attachments = @ptrCast(&color_ref),
2841 };
2842
2843 const dep = vk.SubpassDependency{
2844 .src_subpass = vk.SUBPASS_EXTERNAL,
2845 .dst_subpass = 0,
2846 .src_stage_mask = .{ .color_attachment_output_bit = true },
2847 .dst_stage_mask = .{ .color_attachment_output_bit = true },
2848 .src_access_mask = .{},
2849 .dst_access_mask = .{ .color_attachment_write_bit = true },
2850 };
2851
2852 return try device.vkd.createRenderPass(device.handle, &.{
2853 .attachment_count = 1,
2854 .p_attachments = @ptrCast(&color_attachment),
2855 .subpass_count = 1,
2856 .p_subpasses = @ptrCast(&subpass),
2857 .dependency_count = 1,
2858 .p_dependencies = @ptrCast(&dep),
2859 }, null);
2860 }
2861
2862 pub fn createFramebuffers(
2863 alloc: std.mem.Allocator,
2864 device: *Device,
2865 render_pass: vk.RenderPass,
2866 swapchain: *const Swapchain,
2867 ) ![]vk.Framebuffer {
2868 const fbs = try alloc.alloc(vk.Framebuffer, swapchain.image_views.len);
2869 for (swapchain.image_views, fbs) |view, *fb| {
2870 fb.* = try device.vkd.createFramebuffer(device.handle, &.{
2871 .render_pass = render_pass,
2872 .attachment_count = 1,
2873 .p_attachments = @ptrCast(&view),
2874 .width = swapchain.extent.width,
2875 .height = swapchain.extent.height,
2876 .layers = 1,
2877 }, null);
2878 }
2879 return fbs;
2880 }
2881 ```
2882
2883 - [ ] **Step 2: Build**
2884
2885 ```bash
2886 zig build
2887 ```
2888
2889 Expected: builds.
2890
2891 - [ ] **Step 3: Commit**
2892
2893 ```bash
2894 git add src/renderer.zig
2895 git commit -m "feat(renderer): render pass + framebuffers"
2896 ```
2897
2898 ---
2899
2900 ### Task 6.7: Graphics pipeline
2901
2902 **Files:**
2903 - Modify: `src/renderer.zig`
2904
2905 - [ ] **Step 1: Define vertex/instance formats and pipeline**
2906
2907 Add to `src/renderer.zig`:
2908
2909 ```zig
2910 pub const Instance = extern struct {
2911 cell_pos: [2]f32,
2912 uv_rect: [4]f32,
2913 fg: [4]f32,
2914 bg: [4]f32,
2915 };
2916
2917 pub const Vertex = extern struct {
2918 unit_pos: [2]f32,
2919 };
2920
2921 pub fn createPipeline(
2922 device: *Device,
2923 render_pass: vk.RenderPass,
2924 pipeline_layout: vk.PipelineLayout,
2925 ) !vk.Pipeline {
2926 const vert_spv align(4) = @embedFile("cell.vert.spv").*;
2927 const frag_spv align(4) = @embedFile("cell.frag.spv").*;
2928
2929 const vert_module = try device.vkd.createShaderModule(device.handle, &.{
2930 .code_size = vert_spv.len,
2931 .p_code = @ptrCast(&vert_spv),
2932 }, null);
2933 defer device.vkd.destroyShaderModule(device.handle, vert_module, null);
2934
2935 const frag_module = try device.vkd.createShaderModule(device.handle, &.{
2936 .code_size = frag_spv.len,
2937 .p_code = @ptrCast(&frag_spv),
2938 }, null);
2939 defer device.vkd.destroyShaderModule(device.handle, frag_module, null);
2940
2941 const stages = [_]vk.PipelineShaderStageCreateInfo{
2942 .{
2943 .stage = .{ .vertex_bit = true },
2944 .module = vert_module,
2945 .p_name = "main",
2946 },
2947 .{
2948 .stage = .{ .fragment_bit = true },
2949 .module = frag_module,
2950 .p_name = "main",
2951 },
2952 };
2953
2954 const binding_descs = [_]vk.VertexInputBindingDescription{
2955 .{ .binding = 0, .stride = @sizeOf(Vertex), .input_rate = .vertex },
2956 .{ .binding = 1, .stride = @sizeOf(Instance), .input_rate = .instance },
2957 };
2958
2959 const attr_descs = [_]vk.VertexInputAttributeDescription{
2960 .{ .location = 0, .binding = 0, .format = .r32g32_sfloat, .offset = 0 },
2961 .{ .location = 1, .binding = 1, .format = .r32g32_sfloat, .offset = @offsetOf(Instance, "cell_pos") },
2962 .{ .location = 2, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "uv_rect") },
2963 .{ .location = 3, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "fg") },
2964 .{ .location = 4, .binding = 1, .format = .r32g32b32a32_sfloat, .offset = @offsetOf(Instance, "bg") },
2965 };
2966
2967 const vertex_input = vk.PipelineVertexInputStateCreateInfo{
2968 .vertex_binding_description_count = binding_descs.len,
2969 .p_vertex_binding_descriptions = &binding_descs,
2970 .vertex_attribute_description_count = attr_descs.len,
2971 .p_vertex_attribute_descriptions = &attr_descs,
2972 };
2973
2974 const input_assembly = vk.PipelineInputAssemblyStateCreateInfo{
2975 .topology = .triangle_list,
2976 .primitive_restart_enable = vk.FALSE,
2977 };
2978
2979 const viewport_state = vk.PipelineViewportStateCreateInfo{
2980 .viewport_count = 1,
2981 .scissor_count = 1,
2982 };
2983
2984 const rasterizer = vk.PipelineRasterizationStateCreateInfo{
2985 .depth_clamp_enable = vk.FALSE,
2986 .rasterizer_discard_enable = vk.FALSE,
2987 .polygon_mode = .fill,
2988 .cull_mode = .{},
2989 .front_face = .counter_clockwise,
2990 .depth_bias_enable = vk.FALSE,
2991 .depth_bias_constant_factor = 0,
2992 .depth_bias_clamp = 0,
2993 .depth_bias_slope_factor = 0,
2994 .line_width = 1.0,
2995 };
2996
2997 const multisampling = vk.PipelineMultisampleStateCreateInfo{
2998 .rasterization_samples = .{ .@"1_bit" = true },
2999 .sample_shading_enable = vk.FALSE,
3000 .min_sample_shading = 1.0,
3001 .alpha_to_coverage_enable = vk.FALSE,
3002 .alpha_to_one_enable = vk.FALSE,
3003 };
3004
3005 const color_blend_attachment = vk.PipelineColorBlendAttachmentState{
3006 .blend_enable = vk.FALSE,
3007 .src_color_blend_factor = .one,
3008 .dst_color_blend_factor = .zero,
3009 .color_blend_op = .add,
3010 .src_alpha_blend_factor = .one,
3011 .dst_alpha_blend_factor = .zero,
3012 .alpha_blend_op = .add,
3013 .color_write_mask = .{ .r_bit = true, .g_bit = true, .b_bit = true, .a_bit = true },
3014 };
3015
3016 const color_blend = vk.PipelineColorBlendStateCreateInfo{
3017 .logic_op_enable = vk.FALSE,
3018 .logic_op = .copy,
3019 .attachment_count = 1,
3020 .p_attachments = @ptrCast(&color_blend_attachment),
3021 .blend_constants = [_]f32{ 0, 0, 0, 0 },
3022 };
3023
3024 const dynamic_states = [_]vk.DynamicState{ .viewport, .scissor };
3025 const dynamic_state = vk.PipelineDynamicStateCreateInfo{
3026 .dynamic_state_count = dynamic_states.len,
3027 .p_dynamic_states = &dynamic_states,
3028 };
3029
3030 var pipeline: vk.Pipeline = undefined;
3031 _ = try device.vkd.createGraphicsPipelines(
3032 device.handle,
3033 .null_handle,
3034 1,
3035 @ptrCast(&vk.GraphicsPipelineCreateInfo{
3036 .stage_count = stages.len,
3037 .p_stages = &stages,
3038 .p_vertex_input_state = &vertex_input,
3039 .p_input_assembly_state = &input_assembly,
3040 .p_viewport_state = &viewport_state,
3041 .p_rasterization_state = &rasterizer,
3042 .p_multisample_state = &multisampling,
3043 .p_color_blend_state = &color_blend,
3044 .p_dynamic_state = &dynamic_state,
3045 .layout = pipeline_layout,
3046 .render_pass = render_pass,
3047 .subpass = 0,
3048 .base_pipeline_index = -1,
3049 }),
3050 null,
3051 @ptrCast(&pipeline),
3052 );
3053
3054 return pipeline;
3055 }
3056 ```
3057
3058 - [ ] **Step 2: Build**
3059
3060 ```bash
3061 zig build
3062 ```
3063
3064 Expected: builds.
3065
3066 - [ ] **Step 3: Commit**
3067
3068 ```bash
3069 git add src/renderer.zig
3070 git commit -m "feat(renderer): graphics pipeline with instanced input"
3071 ```
3072
3073 ---
3074
3075 ### Task 6.8: Glyph atlas texture upload
3076
3077 **Files:**
3078 - Modify: `src/renderer.zig`
3079
3080 - [ ] **Step 1: Implement uploadAtlas**
3081
3082 Add helpers for buffer allocation and staging upload:
3083
3084 ```zig
3085 pub const GpuAtlas = struct {
3086 image: vk.Image,
3087 memory: vk.DeviceMemory,
3088 view: vk.ImageView,
3089 sampler: vk.Sampler,
3090 width: u32,
3091 height: u32,
3092 };
3093
3094 pub fn findMemoryType(
3095 vki: InstanceDispatch,
3096 physical: vk.PhysicalDevice,
3097 type_filter: u32,
3098 properties: vk.MemoryPropertyFlags,
3099 ) !u32 {
3100 const mem_props = vki.getPhysicalDeviceMemoryProperties(physical);
3101 var i: u32 = 0;
3102 while (i < mem_props.memory_type_count) : (i += 1) {
3103 if ((type_filter & (@as(u32, 1) << @intCast(i))) != 0 and
3104 mem_props.memory_types[i].property_flags.contains(properties))
3105 {
3106 return i;
3107 }
3108 }
3109 return error.NoSuitableMemoryType;
3110 }
3111
3112 pub fn createAtlasTexture(
3113 vki: InstanceDispatch,
3114 physical: vk.PhysicalDevice,
3115 device: *Device,
3116 width: u32,
3117 height: u32,
3118 ) !GpuAtlas {
3119 const image = try device.vkd.createImage(device.handle, &.{
3120 .image_type = .@"2d",
3121 .format = .r8_unorm,
3122 .extent = .{ .width = width, .height = height, .depth = 1 },
3123 .mip_levels = 1,
3124 .array_layers = 1,
3125 .samples = .{ .@"1_bit" = true },
3126 .tiling = .optimal,
3127 .usage = .{ .transfer_dst_bit = true, .sampled_bit = true },
3128 .sharing_mode = .exclusive,
3129 .initial_layout = .undefined,
3130 }, null);
3131
3132 const mem_reqs = device.vkd.getImageMemoryRequirements(device.handle, image);
3133 const mem_type = try findMemoryType(
3134 vki,
3135 physical,
3136 mem_reqs.memory_type_bits,
3137 .{ .device_local_bit = true },
3138 );
3139
3140 const memory = try device.vkd.allocateMemory(device.handle, &.{
3141 .allocation_size = mem_reqs.size,
3142 .memory_type_index = mem_type,
3143 }, null);
3144
3145 try device.vkd.bindImageMemory(device.handle, image, memory, 0);
3146
3147 const view = try device.vkd.createImageView(device.handle, &.{
3148 .image = image,
3149 .view_type = .@"2d",
3150 .format = .r8_unorm,
3151 .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity },
3152 .subresource_range = .{
3153 .aspect_mask = .{ .color_bit = true },
3154 .base_mip_level = 0,
3155 .level_count = 1,
3156 .base_array_layer = 0,
3157 .layer_count = 1,
3158 },
3159 }, null);
3160
3161 const sampler = try device.vkd.createSampler(device.handle, &.{
3162 .mag_filter = .nearest,
3163 .min_filter = .nearest,
3164 .mipmap_mode = .nearest,
3165 .address_mode_u = .clamp_to_edge,
3166 .address_mode_v = .clamp_to_edge,
3167 .address_mode_w = .clamp_to_edge,
3168 .mip_lod_bias = 0,
3169 .anisotropy_enable = vk.FALSE,
3170 .max_anisotropy = 1,
3171 .compare_enable = vk.FALSE,
3172 .compare_op = .always,
3173 .min_lod = 0,
3174 .max_lod = 0,
3175 .border_color = .int_opaque_black,
3176 .unnormalized_coordinates = vk.FALSE,
3177 }, null);
3178
3179 return .{
3180 .image = image,
3181 .memory = memory,
3182 .view = view,
3183 .sampler = sampler,
3184 .width = width,
3185 .height = height,
3186 };
3187 }
3188
3189 /// Upload CPU pixel data to GPU atlas texture via staging buffer.
3190 /// Caller provides command pool + queue for one-shot submit.
3191 pub fn uploadAtlasPixels(
3192 vki: InstanceDispatch,
3193 physical: vk.PhysicalDevice,
3194 device: *Device,
3195 atlas: *GpuAtlas,
3196 pixels: []const u8,
3197 command_pool: vk.CommandPool,
3198 ) !void {
3199 // Create staging buffer
3200 const staging = try device.vkd.createBuffer(device.handle, &.{
3201 .size = pixels.len,
3202 .usage = .{ .transfer_src_bit = true },
3203 .sharing_mode = .exclusive,
3204 }, null);
3205 defer device.vkd.destroyBuffer(device.handle, staging, null);
3206
3207 const staging_reqs = device.vkd.getBufferMemoryRequirements(device.handle, staging);
3208 const staging_mem_type = try findMemoryType(
3209 vki,
3210 physical,
3211 staging_reqs.memory_type_bits,
3212 .{ .host_visible_bit = true, .host_coherent_bit = true },
3213 );
3214 const staging_mem = try device.vkd.allocateMemory(device.handle, &.{
3215 .allocation_size = staging_reqs.size,
3216 .memory_type_index = staging_mem_type,
3217 }, null);
3218 defer device.vkd.freeMemory(device.handle, staging_mem, null);
3219
3220 try device.vkd.bindBufferMemory(device.handle, staging, staging_mem, 0);
3221
3222 const mapped = try device.vkd.mapMemory(device.handle, staging_mem, 0, pixels.len, .{});
3223 @memcpy(@as([*]u8, @ptrCast(mapped))[0..pixels.len], pixels);
3224 device.vkd.unmapMemory(device.handle, staging_mem);
3225
3226 // One-shot command buffer
3227 var cmd: vk.CommandBuffer = undefined;
3228 _ = try device.vkd.allocateCommandBuffers(device.handle, &.{
3229 .command_pool = command_pool,
3230 .level = .primary,
3231 .command_buffer_count = 1,
3232 }, @ptrCast(&cmd));
3233 defer device.vkd.freeCommandBuffers(device.handle, command_pool, 1, @ptrCast(&cmd));
3234
3235 try device.vkd.beginCommandBuffer(cmd, &.{ .flags = .{ .one_time_submit_bit = true } });
3236
3237 // Transition: undefined -> transfer_dst_optimal
3238 const barrier_to_dst = vk.ImageMemoryBarrier{
3239 .src_access_mask = .{},
3240 .dst_access_mask = .{ .transfer_write_bit = true },
3241 .old_layout = .undefined,
3242 .new_layout = .transfer_dst_optimal,
3243 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
3244 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
3245 .image = atlas.image,
3246 .subresource_range = .{
3247 .aspect_mask = .{ .color_bit = true },
3248 .base_mip_level = 0,
3249 .level_count = 1,
3250 .base_array_layer = 0,
3251 .layer_count = 1,
3252 },
3253 };
3254 device.vkd.cmdPipelineBarrier(
3255 cmd,
3256 .{ .top_of_pipe_bit = true },
3257 .{ .transfer_bit = true },
3258 .{},
3259 0, null,
3260 0, null,
3261 1, @ptrCast(&barrier_to_dst),
3262 );
3263
3264 const region = vk.BufferImageCopy{
3265 .buffer_offset = 0,
3266 .buffer_row_length = 0,
3267 .buffer_image_height = 0,
3268 .image_subresource = .{
3269 .aspect_mask = .{ .color_bit = true },
3270 .mip_level = 0,
3271 .base_array_layer = 0,
3272 .layer_count = 1,
3273 },
3274 .image_offset = .{ .x = 0, .y = 0, .z = 0 },
3275 .image_extent = .{ .width = atlas.width, .height = atlas.height, .depth = 1 },
3276 };
3277 device.vkd.cmdCopyBufferToImage(cmd, staging, atlas.image, .transfer_dst_optimal, 1, @ptrCast(&region));
3278
3279 // Transition: transfer_dst_optimal -> shader_read_only_optimal
3280 const barrier_to_shader = vk.ImageMemoryBarrier{
3281 .src_access_mask = .{ .transfer_write_bit = true },
3282 .dst_access_mask = .{ .shader_read_bit = true },
3283 .old_layout = .transfer_dst_optimal,
3284 .new_layout = .shader_read_only_optimal,
3285 .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
3286 .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED,
3287 .image = atlas.image,
3288 .subresource_range = .{
3289 .aspect_mask = .{ .color_bit = true },
3290 .base_mip_level = 0,
3291 .level_count = 1,
3292 .base_array_layer = 0,
3293 .layer_count = 1,
3294 },
3295 };
3296 device.vkd.cmdPipelineBarrier(
3297 cmd,
3298 .{ .transfer_bit = true },
3299 .{ .fragment_shader_bit = true },
3300 .{},
3301 0, null,
3302 0, null,
3303 1, @ptrCast(&barrier_to_shader),
3304 );
3305
3306 try device.vkd.endCommandBuffer(cmd);
3307
3308 const submit = vk.SubmitInfo{
3309 .command_buffer_count = 1,
3310 .p_command_buffers = @ptrCast(&cmd),
3311 };
3312 _ = try device.vkd.queueSubmit(device.graphics_queue, 1, @ptrCast(&submit), .null_handle);
3313 _ = try device.vkd.queueWaitIdle(device.graphics_queue);
3314 }
3315 ```
3316
3317 - [ ] **Step 2: Build**
3318
3319 ```bash
3320 zig build
3321 ```
3322
3323 Expected: builds.
3324
3325 - [ ] **Step 3: Commit**
3326
3327 ```bash
3328 git add src/renderer.zig
3329 git commit -m "feat(renderer): glyph atlas texture upload via staging buffer"
3330 ```
3331
3332 ---
3333
3334 ### Task 6.9: Per-frame draw — instance buffer + draw call
3335
3336 **Files:**
3337 - Modify: `src/renderer.zig`
3338
3339 - [ ] **Step 1: Implement drawFrame**
3340
3341 Add a high-level draw function that takes:
3342 - An array of `Instance` (one per visible cell)
3343 - The descriptor set bound to the glyph atlas
3344 - The current swapchain image index
3345 - The framebuffer, pipeline, pipeline_layout, command buffer
3346
3347 ```zig
3348 pub const FrameContext = struct {
3349 command_buffer: vk.CommandBuffer,
3350 framebuffer: vk.Framebuffer,
3351 render_pass: vk.RenderPass,
3352 pipeline: vk.Pipeline,
3353 pipeline_layout: vk.PipelineLayout,
3354 descriptor_set: vk.DescriptorSet,
3355 viewport_size: [2]f32,
3356 cell_size: [2]f32,
3357 extent: vk.Extent2D,
3358 instance_buffer: vk.Buffer,
3359 quad_vertex_buffer: vk.Buffer,
3360 instance_count: u32,
3361 };
3362
3363 pub const PushConstants = extern struct {
3364 viewport_size: [2]f32,
3365 cell_size: [2]f32,
3366 };
3367
3368 pub fn recordFrame(device: *Device, ctx: FrameContext) !void {
3369 try device.vkd.beginCommandBuffer(ctx.command_buffer, &.{});
3370
3371 const clear_value = vk.ClearValue{
3372 .color = .{ .float_32 = .{ 0.05, 0.05, 0.05, 1.0 } },
3373 };
3374
3375 device.vkd.cmdBeginRenderPass(ctx.command_buffer, &.{
3376 .render_pass = ctx.render_pass,
3377 .framebuffer = ctx.framebuffer,
3378 .render_area = .{ .offset = .{ .x = 0, .y = 0 }, .extent = ctx.extent },
3379 .clear_value_count = 1,
3380 .p_clear_values = @ptrCast(&clear_value),
3381 }, .@"inline");
3382
3383 device.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);
3384
3385 const viewport = vk.Viewport{
3386 .x = 0, .y = 0,
3387 .width = @floatFromInt(ctx.extent.width),
3388 .height = @floatFromInt(ctx.extent.height),
3389 .min_depth = 0, .max_depth = 1,
3390 };
3391 const scissor = vk.Rect2D{ .offset = .{ .x = 0, .y = 0 }, .extent = ctx.extent };
3392 device.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));
3393 device.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));
3394
3395 const pc = PushConstants{
3396 .viewport_size = ctx.viewport_size,
3397 .cell_size = ctx.cell_size,
3398 };
3399 device.vkd.cmdPushConstants(
3400 ctx.command_buffer,
3401 ctx.pipeline_layout,
3402 .{ .vertex_bit = true },
3403 0,
3404 @sizeOf(PushConstants),
3405 @ptrCast(&pc),
3406 );
3407
3408 device.vkd.cmdBindDescriptorSets(
3409 ctx.command_buffer,
3410 .graphics,
3411 ctx.pipeline_layout,
3412 0,
3413 1,
3414 @ptrCast(&ctx.descriptor_set),
3415 0,
3416 null,
3417 );
3418
3419 const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
3420 const offsets = [_]vk.DeviceSize{ 0, 0 };
3421 device.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);
3422
3423 device.vkd.cmdDraw(ctx.command_buffer, 6, ctx.instance_count, 0, 0);
3424
3425 device.vkd.cmdEndRenderPass(ctx.command_buffer);
3426 try device.vkd.endCommandBuffer(ctx.command_buffer);
3427 }
3428 ```
3429
3430 - [ ] **Step 2: Build**
3431
3432 ```bash
3433 zig build
3434 ```
3435
3436 Expected: builds.
3437
3438 - [ ] **Step 3: Commit**
3439
3440 ```bash
3441 git add src/renderer.zig
3442 git commit -m "feat(renderer): recordFrame with instanced draw"
3443 ```
3444
3445 ---
3446
3447 ## Phase 7: Full Integration
3448
3449 ### Task 7.1: main.zig — init all subsystems
3450
3451 **Files:**
3452 - Modify: `src/main.zig`
3453
3454 - [ ] **Step 1: Write full initialization sequence**
3455
3456 ```zig
3457 const std = @import("std");
3458 const vt = @import("vt");
3459 const pty = @import("pty");
3460 const font = @import("font");
3461 const wayland_mod = @import("wayland-client");
3462 const renderer_mod = @import("renderer");
3463
3464 const FontSize = 14;
3465 const Cols = 80;
3466 const Rows = 24;
3467
3468 pub fn main() !void {
3469 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
3470 defer _ = gpa.deinit();
3471 const alloc = gpa.allocator();
3472
3473 // 1. Wayland connection
3474 var conn = try wayland_mod.Connection.init();
3475 defer conn.deinit();
3476
3477 // 2. Font
3478 var lookup = try font.lookupMonospace(alloc);
3479 defer lookup.deinit(alloc);
3480
3481 var face = try font.Face.init(alloc, lookup.path, lookup.index, FontSize);
3482 defer face.deinit();
3483
3484 const cell_w = face.cellWidth();
3485 const cell_h = face.cellHeight();
3486
3487 // 3. Window (sized to match grid)
3488 const win_w: u32 = @as(u32, Cols) * cell_w;
3489 const win_h: u32 = @as(u32, Rows) * cell_h;
3490
3491 var window = try conn.createWindow("waystty");
3492 defer window.deinit();
3493 window.width = win_w;
3494 window.height = win_h;
3495
3496 // 4. Vulkan renderer (init instance, pick device, create swapchain, etc.)
3497 // This is the big block — see Task 7.2 for the details wired together.
3498
3499 // 5. Atlas
3500 var atlas = try font.Atlas.init(alloc, 1024, 1024);
3501 defer atlas.deinit();
3502
3503 // 6. Terminal
3504 var term = try vt.Terminal.init(alloc, .{
3505 .cols = Cols,
3506 .rows = Rows,
3507 .max_scrollback = 1000,
3508 });
3509 defer term.deinit();
3510
3511 // 7. PTY
3512 const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
3513 var p = try pty.Pty.spawn(.{
3514 .cols = Cols,
3515 .rows = Rows,
3516 .shell = shell,
3517 });
3518 defer p.deinit();
3519
3520 // 8. Encoders
3521 var key_encoder = try vt.KeyEncoder.init(alloc);
3522 defer key_encoder.deinit();
3523
3524 // 9. Main loop — see Task 7.3
3525 std.debug.print("waystty init complete\n", .{});
3526 }
3527 ```
3528
3529 - [ ] **Step 2: Build**
3530
3531 ```bash
3532 zig build
3533 ```
3534
3535 Expected: builds (won't have a working terminal yet — just init).
3536
3537 - [ ] **Step 3: Commit**
3538
3539 ```bash
3540 git add src/main.zig
3541 git commit -m "feat(main): initialize all subsystems"
3542 ```
3543
3544 ---
3545
3546 ### Task 7.2: Vulkan renderer wire-up
3547
3548 **Files:**
3549 - Modify: `src/main.zig`
3550 - Modify: `src/renderer.zig` (add a high-level `Context` bundling all Vulkan state)
3551
3552 - [ ] **Step 1: Add a high-level Renderer.Context that owns everything**
3553
3554 Add to `src/renderer.zig`:
3555
3556 ```zig
3557 pub const Context = struct {
3558 alloc: std.mem.Allocator,
3559 renderer: Renderer,
3560 device_info: DeviceInfo,
3561 device: Device,
3562 surface: vk.SurfaceKHR,
3563 swapchain: Swapchain,
3564 render_pass: vk.RenderPass,
3565 framebuffers: []vk.Framebuffer,
3566 pipeline_layout: vk.PipelineLayout,
3567 pipeline: vk.Pipeline,
3568 descriptor_pool: vk.DescriptorPool,
3569 descriptor_set_layout: vk.DescriptorSetLayout,
3570 descriptor_set: vk.DescriptorSet,
3571 command_pool: vk.CommandPool,
3572 command_buffer: vk.CommandBuffer,
3573 image_available: vk.Semaphore,
3574 render_finished: vk.Semaphore,
3575 in_flight_fence: vk.Fence,
3576 quad_vertex_buffer: vk.Buffer,
3577 quad_vertex_memory: vk.DeviceMemory,
3578 instance_buffer: vk.Buffer,
3579 instance_memory: vk.DeviceMemory,
3580 instance_capacity: u32,
3581 gpu_atlas: GpuAtlas,
3582
3583 pub fn init(
3584 alloc: std.mem.Allocator,
3585 display: *anyopaque,
3586 surface: *anyopaque,
3587 width: u32,
3588 height: u32,
3589 loader: anytype,
3590 ) !Context {
3591 var r = try Renderer.init(alloc, loader);
3592 errdefer r.deinit();
3593
3594 const vk_surface = try r.createWaylandSurface(display, surface);
3595 const info = try r.pickPhysicalDevice(vk_surface);
3596 var device = try r.createDevice(info);
3597 var sc = try r.createSwapchain(&device, info, vk_surface, width, height);
3598
3599 const rp = try createRenderPass(&device, sc.format);
3600 const fbs = try createFramebuffers(alloc, &device, rp, &sc);
3601
3602 // descriptor set layout (single combined image sampler)
3603 const binding = vk.DescriptorSetLayoutBinding{
3604 .binding = 0,
3605 .descriptor_type = .combined_image_sampler,
3606 .descriptor_count = 1,
3607 .stage_flags = .{ .fragment_bit = true },
3608 };
3609 const dsl = try device.vkd.createDescriptorSetLayout(device.handle, &.{
3610 .binding_count = 1,
3611 .p_bindings = @ptrCast(&binding),
3612 }, null);
3613
3614 // push constants
3615 const push_range = vk.PushConstantRange{
3616 .stage_flags = .{ .vertex_bit = true },
3617 .offset = 0,
3618 .size = @sizeOf(PushConstants),
3619 };
3620
3621 const pl = try device.vkd.createPipelineLayout(device.handle, &.{
3622 .set_layout_count = 1,
3623 .p_set_layouts = @ptrCast(&dsl),
3624 .push_constant_range_count = 1,
3625 .p_push_constant_ranges = @ptrCast(&push_range),
3626 }, null);
3627
3628 const pipeline = try createPipeline(&device, rp, pl);
3629
3630 // descriptor pool + set
3631 const pool_size = vk.DescriptorPoolSize{
3632 .type = .combined_image_sampler,
3633 .descriptor_count = 1,
3634 };
3635 const dp = try device.vkd.createDescriptorPool(device.handle, &.{
3636 .max_sets = 1,
3637 .pool_size_count = 1,
3638 .p_pool_sizes = @ptrCast(&pool_size),
3639 }, null);
3640
3641 var ds: vk.DescriptorSet = undefined;
3642 _ = try device.vkd.allocateDescriptorSets(device.handle, &.{
3643 .descriptor_pool = dp,
3644 .descriptor_set_count = 1,
3645 .p_set_layouts = @ptrCast(&dsl),
3646 }, @ptrCast(&ds));
3647
3648 // command pool + buffer
3649 const cp = try device.vkd.createCommandPool(device.handle, &.{
3650 .flags = .{ .reset_command_buffer_bit = true },
3651 .queue_family_index = info.graphics_queue_family,
3652 }, null);
3653
3654 var cb: vk.CommandBuffer = undefined;
3655 _ = try device.vkd.allocateCommandBuffers(device.handle, &.{
3656 .command_pool = cp,
3657 .level = .primary,
3658 .command_buffer_count = 1,
3659 }, @ptrCast(&cb));
3660
3661 // sync
3662 const sem_info = vk.SemaphoreCreateInfo{};
3663 const fence_info = vk.FenceCreateInfo{ .flags = .{ .signaled_bit = true } };
3664 const ia = try device.vkd.createSemaphore(device.handle, &sem_info, null);
3665 const rf = try device.vkd.createSemaphore(device.handle, &sem_info, null);
3666 const iff = try device.vkd.createFence(device.handle, &fence_info, null);
3667
3668 // GPU atlas
3669 const gpu_atlas = try createAtlasTexture(r.vki, info.physical, &device, 1024, 1024);
3670
3671 // Update descriptor set to point at atlas
3672 const img_info = vk.DescriptorImageInfo{
3673 .sampler = gpu_atlas.sampler,
3674 .image_view = gpu_atlas.view,
3675 .image_layout = .shader_read_only_optimal,
3676 };
3677 const write = vk.WriteDescriptorSet{
3678 .dst_set = ds,
3679 .dst_binding = 0,
3680 .dst_array_element = 0,
3681 .descriptor_count = 1,
3682 .descriptor_type = .combined_image_sampler,
3683 .p_image_info = @ptrCast(&img_info),
3684 .p_buffer_info = undefined,
3685 .p_texel_buffer_view = undefined,
3686 };
3687 device.vkd.updateDescriptorSets(device.handle, 1, @ptrCast(&write), 0, null);
3688
3689 // Static quad vertex buffer (6 vertices: two triangles forming unit quad)
3690 const quad_verts = [_]Vertex{
3691 .{ .unit_pos = .{ 0, 0 } },
3692 .{ .unit_pos = .{ 1, 0 } },
3693 .{ .unit_pos = .{ 1, 1 } },
3694 .{ .unit_pos = .{ 0, 0 } },
3695 .{ .unit_pos = .{ 1, 1 } },
3696 .{ .unit_pos = .{ 0, 1 } },
3697 };
3698 const quad_vb_result = try createHostVisibleBuffer(
3699 r.vki, info.physical, &device,
3700 @sizeOf(@TypeOf(quad_verts)),
3701 .{ .vertex_buffer_bit = true },
3702 );
3703 {
3704 const mapped = try device.vkd.mapMemory(device.handle, quad_vb_result.memory, 0, @sizeOf(@TypeOf(quad_verts)), .{});
3705 @memcpy(
3706 @as([*]Vertex, @ptrCast(@alignCast(mapped)))[0..quad_verts.len],
3707 &quad_verts,
3708 );
3709 device.vkd.unmapMemory(device.handle, quad_vb_result.memory);
3710 }
3711
3712 // Pre-allocate instance buffer (large enough for 200x80 grid)
3713 const max_instances: u32 = 200 * 80;
3714 const instance_buffer_size: vk.DeviceSize = @sizeOf(Instance) * max_instances;
3715 const inst_result = try createHostVisibleBuffer(
3716 r.vki, info.physical, &device,
3717 instance_buffer_size,
3718 .{ .vertex_buffer_bit = true },
3719 );
3720
3721 return .{
3722 .alloc = alloc,
3723 .renderer = r,
3724 .device_info = info,
3725 .device = device,
3726 .surface = vk_surface,
3727 .swapchain = sc,
3728 .render_pass = rp,
3729 .framebuffers = fbs,
3730 .pipeline_layout = pl,
3731 .pipeline = pipeline,
3732 .descriptor_pool = dp,
3733 .descriptor_set_layout = dsl,
3734 .descriptor_set = ds,
3735 .command_pool = cp,
3736 .command_buffer = cb,
3737 .image_available = ia,
3738 .render_finished = rf,
3739 .in_flight_fence = iff,
3740 .quad_vertex_buffer = quad_vb_result.buffer,
3741 .quad_vertex_memory = quad_vb_result.memory,
3742 .instance_buffer = inst_result.buffer,
3743 .instance_memory = inst_result.memory,
3744 .instance_capacity = max_instances,
3745 .gpu_atlas = gpu_atlas,
3746 };
3747 }
3748
3749 pub fn deinit(self: *Context) void {
3750 _ = self.device.vkd.deviceWaitIdle(self.device.handle) catch {};
3751 // free all vulkan objects in reverse order
3752 self.device.vkd.destroyFence(self.device.handle, self.in_flight_fence, null);
3753 self.device.vkd.destroySemaphore(self.device.handle, self.render_finished, null);
3754 self.device.vkd.destroySemaphore(self.device.handle, self.image_available, null);
3755 self.device.vkd.destroyCommandPool(self.device.handle, self.command_pool, null);
3756 self.device.vkd.destroyDescriptorPool(self.device.handle, self.descriptor_pool, null);
3757 self.device.vkd.destroyDescriptorSetLayout(self.device.handle, self.descriptor_set_layout, null);
3758 self.device.vkd.destroySampler(self.device.handle, self.gpu_atlas.sampler, null);
3759 self.device.vkd.destroyImageView(self.device.handle, self.gpu_atlas.view, null);
3760 self.device.vkd.destroyImage(self.device.handle, self.gpu_atlas.image, null);
3761 self.device.vkd.freeMemory(self.device.handle, self.gpu_atlas.memory, null);
3762 self.device.vkd.destroyBuffer(self.device.handle, self.quad_vertex_buffer, null);
3763 self.device.vkd.freeMemory(self.device.handle, self.quad_vertex_memory, null);
3764 self.device.vkd.destroyBuffer(self.device.handle, self.instance_buffer, null);
3765 self.device.vkd.freeMemory(self.device.handle, self.instance_memory, null);
3766 self.device.vkd.destroyPipeline(self.device.handle, self.pipeline, null);
3767 self.device.vkd.destroyPipelineLayout(self.device.handle, self.pipeline_layout, null);
3768 for (self.framebuffers) |fb| self.device.vkd.destroyFramebuffer(self.device.handle, fb, null);
3769 self.alloc.free(self.framebuffers);
3770 self.device.vkd.destroyRenderPass(self.device.handle, self.render_pass, null);
3771 for (self.swapchain.image_views) |iv| self.device.vkd.destroyImageView(self.device.handle, iv, null);
3772 self.alloc.free(self.swapchain.image_views);
3773 self.alloc.free(self.swapchain.images);
3774 self.device.vkd.destroySwapchainKHR(self.device.handle, self.swapchain.handle, null);
3775 self.device.vkd.destroyDevice(self.device.handle, null);
3776 self.renderer.vki.destroySurfaceKHR(self.renderer.instance, self.surface, null);
3777 self.renderer.deinit();
3778 }
3779
3780 pub fn uploadInstances(self: *Context, instances: []const Instance) !void {
3781 if (instances.len > self.instance_capacity) return error.InstanceBufferTooSmall;
3782 const size = @sizeOf(Instance) * instances.len;
3783 const mapped = try self.device.vkd.mapMemory(self.device.handle, self.instance_memory, 0, size, .{});
3784 @memcpy(
3785 @as([*]Instance, @ptrCast(@alignCast(mapped)))[0..instances.len],
3786 instances,
3787 );
3788 self.device.vkd.unmapMemory(self.device.handle, self.instance_memory);
3789 }
3790 };
3791
3792 pub const BufferResult = struct { buffer: vk.Buffer, memory: vk.DeviceMemory };
3793
3794 pub fn createHostVisibleBuffer(
3795 vki: InstanceDispatch,
3796 physical: vk.PhysicalDevice,
3797 device: *Device,
3798 size: vk.DeviceSize,
3799 usage: vk.BufferUsageFlags,
3800 ) !BufferResult {
3801 const buf = try device.vkd.createBuffer(device.handle, &.{
3802 .size = size,
3803 .usage = usage,
3804 .sharing_mode = .exclusive,
3805 }, null);
3806
3807 const reqs = device.vkd.getBufferMemoryRequirements(device.handle, buf);
3808 const idx = try findMemoryType(
3809 vki, physical,
3810 reqs.memory_type_bits,
3811 .{ .host_visible_bit = true, .host_coherent_bit = true },
3812 );
3813 const mem = try device.vkd.allocateMemory(device.handle, &.{
3814 .allocation_size = reqs.size,
3815 .memory_type_index = idx,
3816 }, null);
3817 try device.vkd.bindBufferMemory(device.handle, buf, mem, 0);
3818 return .{ .buffer = buf, .memory = mem };
3819 }
3820 ```
3821
3822 - [ ] **Step 2: Build**
3823
3824 ```bash
3825 zig build
3826 ```
3827
3828 Expected: builds.
3829
3830 - [ ] **Step 3: Commit**
3831
3832 ```bash
3833 git add src/renderer.zig
3834 git commit -m "feat(renderer): Context bundle with full Vulkan lifecycle"
3835 ```
3836
3837 ---
3838
3839 ### Task 7.3: Main event loop
3840
3841 **Files:**
3842 - Modify: `src/main.zig`
3843
3844 - [ ] **Step 1: Wire up the full poll() loop**
3845
3846 Replace `src/main.zig`:
3847
3848 ```zig
3849 const std = @import("std");
3850 const vt = @import("vt");
3851 const pty = @import("pty");
3852 const font = @import("font");
3853 const wayland_mod = @import("wayland-client");
3854 const renderer_mod = @import("renderer");
3855
3856 const FontSize = 14;
3857 const Cols = 80;
3858 const Rows = 24;
3859
3860 pub fn main() !void {
3861 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
3862 defer _ = gpa.deinit();
3863 const alloc = gpa.allocator();
3864
3865 // === Wayland ===
3866 var conn = try wayland_mod.Connection.init();
3867 defer conn.deinit();
3868
3869 // === Font ===
3870 var lookup = try font.lookupMonospace(alloc);
3871 defer lookup.deinit(alloc);
3872 var face = try font.Face.init(alloc, lookup.path, lookup.index, FontSize);
3873 defer face.deinit();
3874 const cell_w = face.cellWidth();
3875 const cell_h = face.cellHeight();
3876
3877 // === Window ===
3878 const win_w: u32 = @as(u32, Cols) * cell_w;
3879 const win_h: u32 = @as(u32, Rows) * cell_h;
3880 var window = try conn.createWindow("waystty");
3881 defer window.deinit();
3882 window.width = win_w;
3883 window.height = win_h;
3884 _ = conn.display.roundtrip();
3885
3886 // === Vulkan ===
3887 var ctx = try renderer_mod.Context.init(
3888 alloc,
3889 @ptrCast(conn.display),
3890 @ptrCast(window.surface),
3891 win_w,
3892 win_h,
3893 vkGetInstanceProcAddrStub,
3894 );
3895 defer ctx.deinit();
3896
3897 // === Atlas ===
3898 var atlas = try font.Atlas.init(alloc, 1024, 1024);
3899 defer atlas.deinit();
3900
3901 // === Terminal ===
3902 var term = try vt.Terminal.init(alloc, .{
3903 .cols = Cols,
3904 .rows = Rows,
3905 .max_scrollback = 1000,
3906 });
3907 defer term.deinit();
3908
3909 // === Encoders ===
3910 var key_encoder = try vt.KeyEncoder.init(alloc);
3911 defer key_encoder.deinit();
3912
3913 // === PTY ===
3914 const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
3915 var p = try pty.Pty.spawn(.{
3916 .cols = Cols,
3917 .rows = Rows,
3918 .shell = shell,
3919 });
3920 defer p.deinit();
3921
3922 // === Keyboard ===
3923 var keyboard = try wayland_mod.Keyboard.init(alloc, conn.globals.seat.?);
3924 defer keyboard.deinit(alloc);
3925
3926 // === Main loop ===
3927 const wl_fd = conn.display.getFd();
3928 var pollfds = [_]std.posix.pollfd{
3929 .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
3930 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
3931 };
3932 var render_state = try vt.RenderState.init(alloc);
3933 defer render_state.deinit();
3934
3935 var read_buf: [8192]u8 = undefined;
3936 var encode_buf: [64]u8 = undefined;
3937
3938 while (!window.should_close and p.child_pid > 0) {
3939 // Flush pending wayland requests before polling
3940 _ = conn.display.flush();
3941
3942 _ = std.posix.poll(&pollfds, 10) catch continue;
3943
3944 if (pollfds[0].revents & std.posix.POLL.IN != 0) {
3945 _ = conn.display.dispatch();
3946 }
3947
3948 if (pollfds[1].revents & std.posix.POLL.IN != 0) {
3949 const n = p.read(&read_buf) catch |err| switch (err) {
3950 error.WouldBlock => 0,
3951 else => return err,
3952 };
3953 if (n > 0) try term.write(read_buf[0..n]);
3954 }
3955
3956 // Drain keyboard events
3957 keyboard.tickRepeat();
3958 for (keyboard.event_queue.items) |kev| {
3959 if (kev.action == .release) continue;
3960 try key_encoder.syncFromTerminal(&term);
3961 const len = try key_encoder.encode(&encode_buf, .{
3962 .keysym = kev.keysym,
3963 .modifiers = .{
3964 .ctrl = kev.modifiers.ctrl,
3965 .shift = kev.modifiers.shift,
3966 .alt = kev.modifiers.alt,
3967 .super = kev.modifiers.super,
3968 },
3969 .action = switch (kev.action) {
3970 .press => .press,
3971 .repeat => .repeat,
3972 .release => .release,
3973 },
3974 });
3975 if (len > 0) _ = try p.write(encode_buf[0..len]);
3976 }
3977 keyboard.event_queue.clearRetainingCapacity();
3978
3979 // Render
3980 try render_state.update(&term);
3981 var instances = std.ArrayList(renderer_mod.Instance).init(alloc);
3982 defer instances.deinit();
3983
3984 var row_iter = try render_state.rowIterator();
3985 defer row_iter.deinit();
3986 var row_y: u32 = 0;
3987 while (try row_iter.next()) |row_| {
3988 var row = row_;
3989 defer row.deinit();
3990 var cells = row.cells();
3991 defer cells.deinit();
3992 var col_x: u32 = 0;
3993 while (try cells.next()) |cell| {
3994 if (cell.codepoint == ' ' and cell.bg.r == 0 and cell.bg.g == 0 and cell.bg.b == 0) {
3995 col_x += 1;
3996 continue;
3997 }
3998 const uv = try atlas.getOrInsert(&face, cell.codepoint);
3999 try instances.append(.{
4000 .cell_pos = .{ @floatFromInt(col_x), @floatFromInt(row_y) },
4001 .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
4002 .fg = .{
4003 @as(f32, @floatFromInt(cell.fg.r)) / 255.0,
4004 @as(f32, @floatFromInt(cell.fg.g)) / 255.0,
4005 @as(f32, @floatFromInt(cell.fg.b)) / 255.0,
4006 1.0,
4007 },
4008 .bg = .{
4009 @as(f32, @floatFromInt(cell.bg.r)) / 255.0,
4010 @as(f32, @floatFromInt(cell.bg.g)) / 255.0,
4011 @as(f32, @floatFromInt(cell.bg.b)) / 255.0,
4012 1.0,
4013 },
4014 });
4015 col_x += 1;
4016 }
4017 row_y += 1;
4018 }
4019
4020 // If atlas got new glyphs, reupload
4021 if (atlas.dirty) {
4022 try renderer_mod.uploadAtlasPixels(
4023 ctx.renderer.vki,
4024 ctx.device_info.physical,
4025 &ctx.device,
4026 &ctx.gpu_atlas,
4027 atlas.pixels,
4028 ctx.command_pool,
4029 );
4030 atlas.dirty = false;
4031 }
4032
4033 try ctx.uploadInstances(instances.items);
4034 try renderFrame(&ctx, @intCast(instances.items.len), cell_w, cell_h);
4035 }
4036 }
4037
4038 fn renderFrame(ctx: *renderer_mod.Context, instance_count: u32, cell_w: u32, cell_h: u32) !void {
4039 const vk = @import("vulkan");
4040 _ = vk;
4041
4042 _ = try ctx.device.vkd.waitForFences(ctx.device.handle, 1, @ptrCast(&ctx.in_flight_fence), 1, std.math.maxInt(u64));
4043 try ctx.device.vkd.resetFences(ctx.device.handle, 1, @ptrCast(&ctx.in_flight_fence));
4044
4045 var image_index: u32 = 0;
4046 _ = try ctx.device.vkd.acquireNextImageKHR(
4047 ctx.device.handle,
4048 ctx.swapchain.handle,
4049 std.math.maxInt(u64),
4050 ctx.image_available,
4051 .null_handle,
4052 &image_index,
4053 );
4054
4055 try ctx.device.vkd.resetCommandBuffer(ctx.command_buffer, .{});
4056
4057 try renderer_mod.recordFrame(&ctx.device, .{
4058 .command_buffer = ctx.command_buffer,
4059 .framebuffer = ctx.framebuffers[image_index],
4060 .render_pass = ctx.render_pass,
4061 .pipeline = ctx.pipeline,
4062 .pipeline_layout = ctx.pipeline_layout,
4063 .descriptor_set = ctx.descriptor_set,
4064 .viewport_size = .{
4065 @floatFromInt(ctx.swapchain.extent.width),
4066 @floatFromInt(ctx.swapchain.extent.height),
4067 },
4068 .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
4069 .extent = ctx.swapchain.extent,
4070 .instance_buffer = ctx.instance_buffer,
4071 .quad_vertex_buffer = ctx.quad_vertex_buffer,
4072 .instance_count = instance_count,
4073 });
4074
4075 const wait_stage = @import("vulkan").PipelineStageFlags{ .color_attachment_output_bit = true };
4076 const submit = @import("vulkan").SubmitInfo{
4077 .wait_semaphore_count = 1,
4078 .p_wait_semaphores = @ptrCast(&ctx.image_available),
4079 .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
4080 .command_buffer_count = 1,
4081 .p_command_buffers = @ptrCast(&ctx.command_buffer),
4082 .signal_semaphore_count = 1,
4083 .p_signal_semaphores = @ptrCast(&ctx.render_finished),
4084 };
4085 _ = try ctx.device.vkd.queueSubmit(ctx.device.graphics_queue, 1, @ptrCast(&submit), ctx.in_flight_fence);
4086
4087 const present = @import("vulkan").PresentInfoKHR{
4088 .wait_semaphore_count = 1,
4089 .p_wait_semaphores = @ptrCast(&ctx.render_finished),
4090 .swapchain_count = 1,
4091 .p_swapchains = @ptrCast(&ctx.swapchain.handle),
4092 .p_image_indices = @ptrCast(&image_index),
4093 };
4094 _ = try ctx.device.vkd.queuePresentKHR(ctx.device.present_queue, &present);
4095 }
4096
4097 // vulkan-zig needs a loader. On Linux we use dlsym on libvulkan.so.1
4098 fn vkGetInstanceProcAddrStub(instance: anytype, name: [*:0]const u8) ?*const fn () callconv(.C) void {
4099 // Use dlopen + dlsym to get vkGetInstanceProcAddr from libvulkan.so.1
4100 const dl = @cImport({
4101 @cInclude("dlfcn.h");
4102 });
4103 const handle = dl.dlopen("libvulkan.so.1", dl.RTLD_NOW) orelse return null;
4104 const get_proc: *const fn (instance: anytype, name: [*:0]const u8) ?*const fn () callconv(.C) void =
4105 @ptrCast(@alignCast(dl.dlsym(handle, "vkGetInstanceProcAddr") orelse return null));
4106 return get_proc(instance, name);
4107 }
4108 ```
4109
4110 **Note:** The `vkGetInstanceProcAddrStub` is approximate — the exact signature expected by vulkan-zig's `BaseDispatch.load()` varies. Consult vulkan-zig examples for the correct loader. On Linux, dlopen libvulkan.so.1 and dlsym `vkGetInstanceProcAddr` is the standard approach.
4111
4112 - [ ] **Step 2: Link libdl (for dlopen)**
4113
4114 Add to exe in build.zig:
4115
4116 ```zig
4117 exe.linkSystemLibrary("dl");
4118 exe.linkSystemLibrary("wayland-client");
4119 ```
4120
4121 - [ ] **Step 3: Build and run**
4122
4123 ```bash
4124 zig build run
4125 ```
4126
4127 Expected: opens a window showing a shell prompt. Typing and seeing text appear proves the full loop works.
4128
4129 - [ ] **Step 4: Commit**
4130
4131 ```bash
4132 git add src/main.zig build.zig
4133 git commit -m "feat(main): full event loop integration"
4134 ```
4135
4136 ---
4137
4138 ### Task 7.4: Resize handling
4139
4140 **Files:**
4141 - Modify: `src/main.zig`
4142 - Modify: `src/renderer.zig` (add `recreateSwapchain`)
4143
4144 - [ ] **Step 1: Add recreateSwapchain to Context**
4145
4146 In `src/renderer.zig`:
4147
4148 ```zig
4149 pub fn recreateSwapchain(self: *Context, width: u32, height: u32) !void {
4150 _ = try self.device.vkd.deviceWaitIdle(self.device.handle);
4151
4152 // Destroy old
4153 for (self.framebuffers) |fb| self.device.vkd.destroyFramebuffer(self.device.handle, fb, null);
4154 self.alloc.free(self.framebuffers);
4155 for (self.swapchain.image_views) |iv| self.device.vkd.destroyImageView(self.device.handle, iv, null);
4156 self.alloc.free(self.swapchain.image_views);
4157 self.alloc.free(self.swapchain.images);
4158 self.device.vkd.destroySwapchainKHR(self.device.handle, self.swapchain.handle, null);
4159
4160 // Recreate
4161 self.swapchain = try self.renderer.createSwapchain(&self.device, self.device_info, self.surface, width, height);
4162 self.framebuffers = try createFramebuffers(self.alloc, &self.device, self.render_pass, &self.swapchain);
4163 }
4164 ```
4165
4166 - [ ] **Step 2: Handle configure events in main loop**
4167
4168 In the main loop (before render), check if window dimensions changed:
4169
4170 ```zig
4171 var last_w: u32 = win_w;
4172 var last_h: u32 = win_h;
4173 // ... inside loop, after dispatch:
4174 if (window.width != last_w or window.height != last_h) {
4175 try ctx.recreateSwapchain(window.width, window.height);
4176 const new_cols: u16 = @intCast(window.width / cell_w);
4177 const new_rows: u16 = @intCast(window.height / cell_h);
4178 try term.resize(new_cols, new_rows);
4179 try p.resize(new_cols, new_rows);
4180 last_w = window.width;
4181 last_h = window.height;
4182 }
4183 ```
4184
4185 - [ ] **Step 3: Build and test resize**
4186
4187 ```bash
4188 zig build run
4189 ```
4190
4191 Expected: resize the window; it should not crash and the grid should adjust.
4192
4193 - [ ] **Step 4: Commit**
4194
4195 ```bash
4196 git add src/main.zig src/renderer.zig
4197 git commit -m "feat: handle window resize"
4198 ```
4199
4200 ---
4201
4202 ### Task 7.5: Frame timing instrumentation
4203
4204 **Files:**
4205 - Modify: `src/main.zig`
4206
4207 - [ ] **Step 1: Add frame timer with debug warning**
4208
4209 In the main loop, wrap the render portion:
4210
4211 ```zig
4212 var frame_timer = try std.time.Timer.start();
4213 // ... render ...
4214 const frame_ns = frame_timer.read();
4215 if (std.debug.runtime_safety and frame_ns > 16 * std.time.ns_per_ms) {
4216 std.debug.print("slow frame: {d}ms\n", .{@divTrunc(frame_ns, std.time.ns_per_ms)});
4217 }
4218 ```
4219
4220 - [ ] **Step 2: Build**
4221
4222 ```bash
4223 zig build
4224 ```
4225
4226 Expected: builds. In debug mode, slow frames print a warning.
4227
4228 - [ ] **Step 3: Commit**
4229
4230 ```bash
4231 git add src/main.zig
4232 git commit -m "feat: frame timing warning in debug builds"
4233 ```
4234
4235 ---
4236
4237 ## Self-Review Checklist
4238
4239 After completing the plan above, the reviewer should verify:
4240
4241 - [ ] `zig build` succeeds
4242 - [ ] `zig build test` passes (pty, vt, font tests)
4243 - [ ] `zig build run -- --headless` dumps a grid including "hello"
4244 - [ ] `zig build run` opens a window with a working shell
4245 - [ ] Typing in the window produces expected characters
4246 - [ ] Resizing the window doesn't crash and adjusts the grid
4247 - [ ] Running `vim` inside waystty works (tests key encoding, cursor movement, redraws)
4248 - [ ] Running `htop` inside waystty works (tests refresh rate, color, block characters)
4249 - [ ] Exit via `exit` command closes the window cleanly
4250
4251 ## Spec Coverage Map
4252
4253 | Spec Section | Implementing Tasks |
4254 |--------------|-------------------|
4255 | Architecture: 6 modules | 0.3, 1.x, 2.x, 4.x, 5.x, 6.x, 7.x |
4256 | wayland.zig protocols | 5.1, 5.2, 5.3 |
4257 | Key repeat | 5.5 |
4258 | Focus events | 5.4 (enter/leave) |
4259 | Cursor shape | *Deferred — add as follow-up task* |
4260 | DPI scaling | *Deferred — add as follow-up task* |
4261 | Clipboard | *Phase 2 per spec* |
4262 | pty.zig | 1.x |
4263 | vt.zig all handles | 2.1–2.7 |
4264 | Effect callbacks | *Implementer must wire these in 2.2+ per observed upstream API* |
4265 | font.zig (fontconfig, freetype) | 4.1, 4.2 |
4266 | Glyph atlas R8 | 4.3 |
4267 | Harfbuzz | *Phase 2 per spec* |
4268 | Cell metrics | 4.2 (cellWidth/cellHeight) |
4269 | renderer.zig Vulkan | 6.1–6.9, 7.2 |
4270 | Shaders glslc | 6.2 |
4271 | Instanced draw | 6.9, 7.3 |
4272 | Swapchain recreation | 7.4 |
4273 | Event loop with poll | 7.3 |
4274 | Resize handling | 7.4 |
4275 | Hardcoded defaults | 7.3 (constants at top of main.zig) |
4276 | Build system (build.zig only) | 0.2, 0.3, 0.4, 1.1, 2.2, 4.1, 5.1, 6.1, 6.2 |
4277 | Testing (zig test) | throughout via `zig build test` |
4278 | Frame timing | 7.5 |
4279
4280 ### Known Gaps (acknowledge before starting)
4281
4282 1. **Cursor shape + DPI scaling** — spec mentions these but no tasks. Add as follow-up tasks after the terminal is functional.
4283 2. **Focus encoding via ghostty_focus_encode** — spec mentions this; implementer must add to the keyboard enter/leave handler in Task 5.4 or as a follow-up.
4284 3. **`GHOSTTY_TERMINAL_OPT_*` effect callbacks** — the exact registration happens via the upstream Zig API, which we don't fully know until Task 2.1. The implementer must set WRITE_PTY at minimum, along with DA, SIZE, XTVERSION, TITLE_CHANGED, COLOR_SCHEME per spec.
4285 4. **vulkan-zig loader** — the `vkGetInstanceProcAddrStub` in Task 7.3 is approximate. Must be adjusted to match vulkan-zig's expected signature.
4286 5. **zig-wayland scanner API** — has churned across Zig versions. The build.zig snippets in Task 5.1 are approximate and must be adapted to the pinned dependency version.
4287
4288 These gaps are called out because this plan covers a very large surface (Wayland + Vulkan + libghostty-vt + freetype + xkb), and small API drift in any of the five dependencies will need small adjustments during implementation.